quizapp
51.3%
Statements
9348/18220
cmd
0.6%
8/1288
internal
55.2%
9340/16932
quizapp cmd
0.6%
Statements
8/1288
adm
0.0%
0/201
cli-worker
5.1%
8/158
reset-db
0.0%
0/76
server
0.0%
0/96
setup-test-db
0.0%
0/619
worker
0.0%
0/138
quizapp cmd adm
0.0%
Statements
0/201
commands
0.0%
0/161
main.go
0.0%
0/40
quizapp cmd adm main.go
0.0%
Statements
0/161
db.go
0.0%
0/43
translations.go
0.0%
0/25
user.go
0.0%
0/77
utils.go
0.0%
0/16
quizapp cmd adm commands db.go
0.0%
Statements
0/43
1
// Package commands provides CLI commands for the admin tool
2
package commands
3

4
import (
5
    "context"
6
    "database/sql"
7
    "os"
8

9
    "quizapp/internal/observability"
10
    "quizapp/internal/services"
11
    contextutils "quizapp/internal/utils"
12

13
    "github.com/spf13/cobra"
14
)
15

16
// DatabaseCommands returns the database management commands
17
func DatabaseCommands(userService *services.UserService, logger *observability.Logger, db *sql.DB) *cobra.Command {
18
    dbCmd := &cobra.Command{
19
        Use:   "db",
20
        Short: "Database management commands",
21
        Long: `Database management commands for the quiz application.
22

23
Available commands:
24
  stats     - Show database statistics
25
  cleanup   - Run database cleanup operations`,
26
    }
27

28
    // Add subcommands
29
    dbCmd.AddCommand(statsCmd(userService, logger, db))
30
    dbCmd.AddCommand(cleanupCmd(logger, db))
31

32
    return dbCmd
33
}
34

35
// statsCmd returns the stats command
36
func statsCmd(userService *services.UserService, logger *observability.Logger, db *sql.DB) *cobra.Command {
37
    return &cobra.Command{
38
        Use:   "stats",
39
        Short: "Show database statistics",
40
        Long:  `Show database statistics including user counts and other metrics.`,
41
        RunE:  runStats(userService, logger, db),
42
    }
43
}
44

45
// cleanupCmd returns the cleanup command
46
func cleanupCmd(logger *observability.Logger, db *sql.DB) *cobra.Command {
47
    var statsOnly bool
48

49
    cmd := &cobra.Command{
50
        Use:   "cleanup",
51
        Short: "Run database cleanup operations",
52
        Long: `Run database cleanup operations to remove old data.
53

54
This command will:
55
- Remove questions with legacy question types
56
- Remove orphaned user responses
57

58
Use --stats flag to see what would be cleaned up without actually performing the cleanup.`,
59
        RunE: runCleanup(logger, &statsOnly, db),
60
    }
61

62
    // Add flags
63
    cmd.Flags().BoolVar(&statsOnly, "stats", false, "Only show cleanup statistics, don't perform cleanup")
64

65
    return cmd
66
}
67

68
// runStats returns a function that shows database statistics
69
func runStats(userService *services.UserService, logger *observability.Logger, db *sql.DB) func(cmd *cobra.Command, args []string) error {
70
    return func(_ *cobra.Command, _ []string) error {
71
        ctx := context.Background()
72

73
        // Log diagnostic information
74
        logger.Info(ctx, "Diagnostic info", map[string]interface{}{"config_file": os.Getenv("QUIZ_CONFIG_FILE"), "database": getDatabaseInfo(db)})
75

76
        logger.Info(ctx, "Showing database statistics", map[string]interface{}{})
77

78
        // Get user statistics
79
        users, err := userService.GetAllUsers(ctx)
80
        if err != nil {
81
            logger.Error(ctx, "Failed to get user statistics", err, map[string]interface{}{})
82
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to get user statistics: %v", err)
83
        }
84

85
        logger.Info(ctx, "Database statistics", map[string]interface{}{"total_users": len(users), "database": "PostgreSQL", "status": "Connected"})
86

87
        return nil
88
    }
89
}
90

91
// runCleanup returns a function that runs database cleanup
92
func runCleanup(logger *observability.Logger, statsOnly *bool, db *sql.DB) func(cmd *cobra.Command, args []string) error {
93
    return func(_ *cobra.Command, _ []string) error {
94
        ctx := context.Background()
95

96
        // Log diagnostic information
97
        logger.Info(ctx, "Diagnostic info", map[string]interface{}{"config_file": os.Getenv("QUIZ_CONFIG_FILE"), "database": getDatabaseInfo(db)})
98

99
        logger.Info(ctx, "Running database cleanup", map[string]interface{}{"stats_only": *statsOnly})
100

101
        // Use the database connection passed as parameter
102
        if db == nil {
103
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "database connection not available")
104
        }
105

106
        // Initialize cleanup service
107
        cleanupService := services.NewCleanupServiceWithLogger(db, logger)
108

109
        if *statsOnly {
110
            // Show cleanup statistics only
111
            stats, err := cleanupService.GetCleanupStats(ctx)
112
            if err != nil {
113
                logger.Error(ctx, "Failed to get cleanup stats", err, map[string]interface{}{"stats_only": true})
114
                return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to get cleanup stats: %v", err)
115
            }
116

117
            logger.Info(ctx, "Database cleanup statistics", map[string]interface{}{"legacy_questions": stats["legacy_questions"], "orphaned_responses": stats["orphaned_responses"]})
118

119
            total := stats["legacy_questions"] + stats["orphaned_responses"]
120
            if total == 0 {
121
                logger.Info(ctx, "No cleanup needed - database is clean", map[string]interface{}{"total": total})
122
            } else {
123
                logger.Info(ctx, "Cleanup would remove items", map[string]interface{}{"total": total})
124
            }
125
            return nil
126
        }
127

128
        // Run full cleanup
129
        logger.Info(ctx, "Starting database cleanup", map[string]interface{}{"service": "cleanup"})
130

131
        if err := cleanupService.RunFullCleanup(ctx); err != nil {
132
            logger.Error(ctx, "Cleanup failed", err, map[string]interface{}{"service": "cleanup"})
133
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "cleanup failed: %v", err)
134
        }
135

136
        logger.Info(ctx, "Database cleanup completed successfully", map[string]interface{}{"service": "cleanup"})
137
        return nil
138
    }
139
}
140


			
quizapp cmd adm commands translations.go
0.0%
Statements
0/25
1
// Package commands provides CLI commands for the admin tool
2
package commands
3

4
import (
5
    "context"
6
    "database/sql"
7
    "fmt"
8

9
    "quizapp/internal/observability"
10
    "quizapp/internal/services"
11
    contextutils "quizapp/internal/utils"
12

13
    "github.com/spf13/cobra"
14
)
15

16
// TranslationCommands returns the translation management commands
17
func TranslationCommands(logger *observability.Logger, db *sql.DB) *cobra.Command {
18
    translationCmd := &cobra.Command{
19
        Use:   "translation",
20
        Short: "Translation cache management commands",
21
        Long: `Translation cache management commands for the quiz application.
22

23
Available commands:
24
  cleanup   - Remove expired translation cache entries`,
25
    }
26

27
    // Add subcommands
28
    translationCmd.AddCommand(translationCleanupCmd(logger, db))
29

30
    return translationCmd
31
}
32

33
// translationCleanupCmd returns the cleanup command for translation cache
34
func translationCleanupCmd(logger *observability.Logger, db *sql.DB) *cobra.Command {
35
    var dryRun bool
36

37
    cmd := &cobra.Command{
38
        Use:   "cleanup",
39
        Short: "Remove expired translation cache entries",
40
        Long: `Remove expired translation cache entries from the database.
41

42
This command will:
43
- Delete all translation cache entries that have expired (older than 30 days)
44
- Report the number of entries deleted
45

46
Use --dry-run flag to see what would be cleaned up without actually performing the cleanup.`,
47
        RunE: runTranslationCleanup(logger, &dryRun, db),
48
    }
49

50
    cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be cleaned up without actually performing the cleanup")
51

52
    return cmd
53
}
54

55
// runTranslationCleanup executes the translation cache cleanup
56
func runTranslationCleanup(logger *observability.Logger, dryRun *bool, db *sql.DB) func(*cobra.Command, []string) error {
57
    return func(_ *cobra.Command, _ []string) error {
58
        ctx := context.Background()
59
        cacheRepo := services.NewTranslationCacheRepository(db, logger)
60

61
        if *dryRun {
62
            // Count expired entries without deleting
63
            var count int64
64
            err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM translation_cache WHERE expires_at < NOW()").Scan(&count)
65
            if err != nil {
66
                logger.Error(ctx, "Failed to count expired translation cache entries", err)
67
                return contextutils.WrapError(err, "failed to count expired entries")
68
            }
69

70
            fmt.Printf("Dry run: Would delete %d expired translation cache entries\n", count)
71
            return nil
72
        }
73

74
        // Perform actual cleanup
75
        count, err := cacheRepo.CleanupExpiredTranslations(ctx)
76
        if err != nil {
77
            logger.Error(ctx, "Failed to cleanup expired translation cache entries", err)
78
            return contextutils.WrapError(err, "failed to cleanup expired entries")
79
        }
80

81
        fmt.Printf("Successfully deleted %d expired translation cache entries\n", count)
82
        logger.Info(ctx, "Translation cache cleanup completed", map[string]interface{}{
83
            "deleted_count": count,
84
        })
85

86
        return nil
87
    }
88
}
89


			
quizapp cmd adm commands user.go
0.0%
Statements
0/77
1
package commands
2

3
import (
4
    "context"
5
    "fmt"
6
    "os"
7
    "syscall"
8

9
    "golang.org/x/term"
10

11
    "quizapp/internal/observability"
12
    "quizapp/internal/services"
13
    contextutils "quizapp/internal/utils"
14

15
    "github.com/spf13/cobra"
16
)
17

18
// UserCommands returns the user management commands
19
func UserCommands(userService *services.UserService, logger *observability.Logger, databaseURL string) *cobra.Command {
20
    userCmd := &cobra.Command{
21
        Use:   "user",
22
        Short: "User management commands",
23
        Long: `User management commands for the quiz application.
24

25
Available commands:
26
  list     - List all users
27
  reset-password - Reset password for a specific user`,
28
    }
29

30
    // Add subcommands
31
    userCmd.AddCommand(listCmd(userService, logger, databaseURL))
32
    userCmd.AddCommand(resetPasswordCmd(userService, logger))
33

34
    return userCmd
35
}
36

37
// listCmd returns the list command
38
func listCmd(userService *services.UserService, logger *observability.Logger, databaseURL string) *cobra.Command {
39
    return &cobra.Command{
40
        Use:   "list",
41
        Short: "List all users",
42
        Long:  `List all users in the database with their basic information.`,
43
        RunE:  runListUsers(userService, logger, databaseURL),
44
    }
45
}
46

47
// resetPasswordCmd returns the reset-password command
48
func resetPasswordCmd(userService *services.UserService, logger *observability.Logger) *cobra.Command {
49
    return &cobra.Command{
50
        Use:   "reset-password [username]",
51
        Short: "Reset password for a user",
52
        Long:  `Reset the password for a specific user. If username is not provided, you will be prompted for it.`,
53
        RunE:  runResetPassword(userService, logger),
54
    }
55
}
56

57
// runListUsers returns a function that lists all users
58
func runListUsers(userService *services.UserService, logger *observability.Logger, databaseURL string) func(cmd *cobra.Command, args []string) error {
59
    return func(_ *cobra.Command, _ []string) error {
60
        ctx := context.Background()
61

62
        // Show diagnostic information
63
        logger.Info(ctx, "Admin command diagnostics", map[string]interface{}{"config_file": os.Getenv("QUIZ_CONFIG_FILE"), "database_url": maskDatabaseURL(databaseURL)})
64

65
        logger.Info(ctx, "Listing all users", map[string]interface{}{})
66

67
        users, err := userService.GetAllUsers(ctx)
68
        if err != nil {
69
            logger.Error(ctx, "Failed to get users", err, map[string]interface{}{})
70
            return contextutils.WrapError(err, "failed to get users")
71
        }
72

73
        if len(users) == 0 {
74
            logger.Info(ctx, "No users found in the database", nil)
75
            return nil
76
        }
77

78
        // Print header to stdout (user-facing table)
79
        fmt.Printf("%-5s %-20s %-30s %-15s %-10s %-10s %-10s\n", "ID", "Username", "Email", "Language", "Level", "AI Enabled", "Created")
80
        fmt.Println(string(make([]byte, 120))) // Print 120 dashes
81

82
        // Print each user
83
        for _, user := range users {
84
            aiEnabled := "No"
85
            if user.AIEnabled.Valid && user.AIEnabled.Bool {
86
                aiEnabled = "Yes"
87
            }
88

89
            email := "N/A"
90
            if user.Email.Valid {
91
                email = user.Email.String
92
            }
93

94
            language := "N/A"
95
            if user.PreferredLanguage.Valid {
96
                language = user.PreferredLanguage.String
97
            }
98

99
            level := "N/A"
100
            if user.CurrentLevel.Valid {
101
                level = user.CurrentLevel.String
102
            }
103

104
            fmt.Printf("%-5d %-20s %-30s %-15s %-10s %-10s %-10s\n",
105
                user.ID,
106
                user.Username,
107
                email,
108
                language,
109
                level,
110
                aiEnabled,
111
                user.CreatedAt.Format("2006-01-02"),
112
            )
113
        }
114

115
        logger.Info(ctx, "Listed users", map[string]interface{}{"total": len(users)})
116
        return nil
117
    }
118
}
119

120
// runResetPassword returns a function that resets a user's password
121
func runResetPassword(userService *services.UserService, logger *observability.Logger) func(cmd *cobra.Command, args []string) error {
122
    return func(_ *cobra.Command, args []string) error {
123
        ctx := context.Background()
124

125
        var username string
126
        var newPassword string
127

128
        // Get username from args or prompt
129
        if len(args) > 0 {
130
            username = args[0]
131
        } else {
132
            fmt.Print("Enter username: ")
133
            if _, err := fmt.Scanln(&username); err != nil {
134
                return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to read username: %v", err)
135
            }
136
        }
137

138
        if username == "" {
139
            return contextutils.ErrorWithContextf("username is required")
140
        }
141

142
        // Prompt for password securely
143
        fmt.Print("Enter new password: ")
144
        passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
145
        if err != nil {
146
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to read password: %v", err)
147
        }
148
        newPassword = string(passwordBytes)
149
        fmt.Println() // New line after password input
150

151
        if newPassword == "" {
152
            return contextutils.ErrorWithContextf("password cannot be empty")
153
        }
154

155
        // Confirm password
156
        fmt.Print("Confirm new password: ")
157
        confirmBytes, err := term.ReadPassword(int(syscall.Stdin))
158
        if err != nil {
159
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to read password confirmation: %v", err)
160
        }
161
        confirmPassword := string(confirmBytes)
162
        fmt.Println() // New line after password input
163

164
        if newPassword != confirmPassword {
165
            return contextutils.ErrorWithContextf("passwords do not match")
166
        }
167

168
        logger.Info(ctx, "Resetting password for user", map[string]interface{}{
169
            "username": username,
170
        })
171

172
        // Get user by username
173
        user, err := userService.GetUserByUsername(ctx, username)
174
        if err != nil {
175
            logger.Error(ctx, "Failed to get user", err, map[string]interface{}{"username": username})
176
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to get user '%s': %v", username, err)
177
        }
178

179
        if user == nil {
180
            logger.Error(ctx, "User not found", nil, map[string]interface{}{"username": username})
181
            return contextutils.ErrorWithContextf("user '%s' not found", username)
182
        }
183

184
        // Update the password
185
        err = userService.UpdateUserPassword(ctx, user.ID, newPassword)
186
        if err != nil {
187
            logger.Error(ctx, "Failed to update password", err, map[string]interface{}{
188
                "username": username,
189
                "user_id":  user.ID,
190
            })
191
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to update password for user '%s': %v", username, err)
192
        }
193

194
        fmt.Printf("â Password successfully reset for user '%s' (ID: %d)\n", username, user.ID)
195
        logger.Info(ctx, "Password reset successful", map[string]interface{}{
196
            "username": username,
197
            "user_id":  user.ID,
198
        })
199

200
        return nil
201
    }
202
}
203


			
quizapp cmd adm commands utils.go
0.0%
Statements
0/16
1
package commands
2

3
import (
4
    "database/sql"
5
    "fmt"
6
    "strings"
7
)
8

9
// maskDatabaseURL masks sensitive parts of the database URL for display
10
func maskDatabaseURL(url string) string {
11
    // Simple masking for display purposes
12
    if strings.Contains(url, "@") {
13
        parts := strings.Split(url, "@")
14
        if len(parts) == 2 {
15
            return "postgres://***:***@" + parts[1]
16
        }
17
    }
18
    return url
19
}
20

21
// getDatabaseInfo returns database connection information
22
func getDatabaseInfo(db *sql.DB) string {
23
    if db == nil {
24
        return "Not connected"
25
    }
26

27
    // Try to get database name
28
    var dbName string
29
    err := db.QueryRow("SELECT current_database()").Scan(&dbName)
30
    if err != nil {
31
        return "Connected (unknown database)"
32
    }
33

34
    // Try to get host information
35
    var host string
36
    err = db.QueryRow("SELECT inet_server_addr()::text").Scan(&host)
37
    if err != nil {
38
        return fmt.Sprintf("Connected to %s", dbName)
39
    }
40

41
    return fmt.Sprintf("Connected to %s on %s", dbName, host)
42
}
43


			
quizapp cmd adm main.go
0.0%
Statements
0/40
1
// Package main provides the main entry point for the quiz application admin CLI tool.
2
package main
3

4
import (
5
    "context"
6
    "fmt"
7
    "os"
8

9
    "quizapp/cmd/adm/commands"
10
    "quizapp/internal/config"
11
    "quizapp/internal/database"
12
    "quizapp/internal/observability"
13
    "quizapp/internal/services"
14

15
    "github.com/spf13/cobra"
16
)
17

18
// Global variables for shared resources
19
var (
20
    cfg         *config.Config
21
    logger      *observability.Logger
22
    userService *services.UserService
23
)
24

25
func main() {
26
    ctx := context.Background()
27

28
    // Set default config file if not already set
29
    if os.Getenv("QUIZ_CONFIG_FILE") == "" {
30
        // Try to find the config file in common locations
31
        defaultPaths := []string{
32
            "../merged.config.yaml",    // From backend/cmd/adm/
33
            "../../merged.config.yaml", // From backend/cmd/adm/ (alternative)
34
            "merged.config.yaml",       // Current directory
35
        }
36

37
        for _, path := range defaultPaths {
38
            if _, err := os.Stat(path); err == nil {
39
                if err := os.Setenv("QUIZ_CONFIG_FILE", path); err != nil {
40
                    fmt.Fprintf(os.Stderr, "Failed to set QUIZ_CONFIG_FILE environment variable: %v\n", err)
41
                    os.Exit(1)
42
                }
43
                break
44
            }
45
        }
46
    }
47

48
    // Load configuration
49
    var err error
50
    cfg, err = config.NewConfig()
51
    if err != nil {
52
        fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
53
        os.Exit(1)
54
    }
55

56
    // Override log level for admin tool
57
    cfg.Server.LogLevel = "error"
58

59
    // Disable all OpenTelemetry features for admin CLI to avoid connection errors
60
    cfg.OpenTelemetry.EnableTracing = false
61
    cfg.OpenTelemetry.EnableMetrics = false
62
    cfg.OpenTelemetry.EnableLogging = false
63

64
    // Setup observability (tracing/metrics/logging)
65
    _, _, loggerInstance, err := observability.SetupObservability(&cfg.OpenTelemetry, "quiz-admin")
66
    if err != nil {
67
        fmt.Fprintf(os.Stderr, "Failed to initialize observability: %v\n", err)
68
        os.Exit(1)
69
    }
70

71
    // Store logger globally
72
    logger = loggerInstance
73

74
    // Initialize database manager
75
    dbManager := database.NewManager(logger)
76

77
    // Initialize database connection with configuration (no migrations for admin tool)
78
    db, err := dbManager.InitDBWithoutMigrations(cfg.Database)
79
    if err != nil {
80
        logger.Error(ctx, "Failed to connect to database", err, map[string]interface{}{"db_url": cfg.Database.URL})
81
        os.Exit(1)
82
    }
83
    defer func() {
84
        if err := db.Close(); err != nil {
85
            logger.Warn(ctx, "Warning: failed to close database connection", map[string]interface{}{"error": err.Error(), "db_url": cfg.Database.URL})
86
        }
87
    }()
88

89
    // Initialize services
90
    userService = services.NewUserServiceWithLogger(db, cfg, logger)
91

92
    // Create the root command
93
    rootCmd := &cobra.Command{
94
        Use:   "adm",
95
        Short: "Quiz Application Administration Tool",
96
        Long: `Quiz Application Administration Tool
97

98
A comprehensive CLI tool for administering the quiz application.
99
Provides commands for user management, database operations, and system administration.`,
100

101
        Run: func(cmd *cobra.Command, _ []string) {
102
            // Show help if no subcommand provided
103
            if err := cmd.Help(); err != nil {
104
                fmt.Printf("Error showing help: %v\n", err)
105
            }
106
        },
107
    }
108

109
    // Add subcommands with initialized services
110
    rootCmd.AddCommand(commands.UserCommands(userService, logger, cfg.Database.URL))
111
    rootCmd.AddCommand(commands.DatabaseCommands(userService, logger, db))
112
    rootCmd.AddCommand(commands.TranslationCommands(logger, db))
113

114
    // Execute the command
115
    if err := rootCmd.Execute(); err != nil {
116
        os.Exit(1)
117
    }
118
}
119


			
quizapp cmd cli-worker
5.1%
Statements
8/158
main.go
5.1%
8/158
quizapp cmd cli-worker main.go
5.1%
Statements
8/158
1
// Package main provides a CLI tool for running the worker to generate questions for a specific user.
2
package main
3

4
import (
5
    "context"
6
    "flag"
7
    "fmt"
8
    "os"
9
    "strings"
10
    "time"
11

12
    "quizapp/internal/config"
13
    "quizapp/internal/database"
14
    "quizapp/internal/models"
15
    "quizapp/internal/observability"
16
    "quizapp/internal/services"
17
    "quizapp/internal/worker"
18
)
19

20
func main() {
21
    ctx := context.Background()
22
    // Define command line flags
23
    var (
24
        username     = flag.String("username", "", "Username to generate questions for (required)")
25
        level        = flag.String("level", "", "Override user's current level (optional)")
26
        language     = flag.String("language", "", "Override user's preferred language (optional)")
27
        questionType = flag.String("type", "vocabulary", "Question type: vocabulary, fill_blank, qa, reading_comprehension")
28
        topic        = flag.String("topic", "", "Specific topic for questions (optional)")
29
        count        = flag.Int("count", 5, "Number of questions to generate")
30
        aiProvider   = flag.String("ai-provider", "", "Override AI provider (optional)")
31
        aiModel      = flag.String("ai-model", "", "Override AI model (optional)")
32
        aiAPIKey     = flag.String("ai-api-key", "", "Override AI API key (optional)")
33
        help         = flag.Bool("help", false, "Show help message")
34
    )
35

36
    flag.Parse()
37

38
    if *help {
39
        printUsage(nil)
40
        return
41
    }
42

43
    if *username == "" {
44
        fmt.Fprintln(os.Stderr, "Error: --username flag is required")
45
        os.Exit(1)
46
    }
47

48
    // Load configuration
49
    cfg, err := config.NewConfig()
50
    if err != nil {
51
        fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
52
        os.Exit(1)
53
    }
54

55
    // Setup observability (tracing/metrics/logging)
56
    _, _, logger, err := observability.SetupObservability(&cfg.OpenTelemetry, "quiz-cli-worker")
57
    if err != nil {
58
        fmt.Fprintf(os.Stderr, "Failed to initialize observability: %v\n", err)
59
        os.Exit(1)
60
    }
61

62
    logger.Info(ctx, "Starting quiz CLI worker", map[string]interface{}{
63
        "username":      *username,
64
        "question_type": *questionType,
65
        "count":         *count,
66
    })
67

68
    // Validate question type
69
    validTypes := map[string]models.QuestionType{
70
        "vocabulary":            models.Vocabulary,
71
        "fill_blank":            models.FillInBlank,
72
        "qa":                    models.QuestionAnswer,
73
        "reading_comprehension": models.ReadingComprehension,
74
    }
75

76
    qType, valid := validTypes[strings.ToLower(*questionType)]
77
    if !valid {
78
        logger.Error(ctx, "Invalid question type", nil, map[string]interface{}{"question_type": *questionType})
79
        fmt.Fprintf(os.Stderr, "Error: Invalid question type '%s'\n", *questionType)
80
        os.Exit(1)
81
    }
82

83
    // Validate level if provided
84
    if *level != "" {
85
        if !isValidLevel(*level, cfg.GetAllLevels()) {
86
            logger.Error(ctx, "Invalid level", nil, map[string]interface{}{"level": *level})
87
            fmt.Fprintf(os.Stderr, "Error: Invalid level '%s'\n", *level)
88
            os.Exit(1)
89
        }
90
    }
91

92
    // Validate language if provided (use dynamic list from config)
93
    validLanguages := cfg.GetLanguages()
94
    if *language != "" {
95
        if !isValidLanguage(*language, validLanguages) {
96
            logger.Error(ctx, "Invalid language", nil, map[string]interface{}{"language": *language})
97
            fmt.Fprintf(os.Stderr, "Error: Invalid language '%s'\n", *language)
98
            os.Exit(1)
99
        }
100
    }
101

102
    // Initialize database manager with logger
103
    dbManager := database.NewManager(logger)
104

105
    // Initialize database connection with configuration
106
    db, err := dbManager.InitDBWithoutMigrations(cfg.Database)
107
    if err != nil {
108
        logger.Error(ctx, "Failed to connect to database", err, map[string]interface{}{"db_url": cfg.Database.URL})
109
        fmt.Fprintf(os.Stderr, "Failed to connect to database: %v\n", err)
110
        os.Exit(1)
111
    }
112
    defer func() {
113
        if err := db.Close(); err != nil {
114
            logger.Warn(ctx, "Warning: failed to close database connection", map[string]interface{}{"error": err.Error(), "db_url": cfg.Database.URL})
115
        }
116
    }()
117

118
    // Initialize services
119
    userService := services.NewUserServiceWithLogger(db, cfg, logger)
120
    learningService := services.NewLearningServiceWithLogger(db, cfg, logger)
121
    // Create question service
122
    questionService := services.NewQuestionServiceWithLogger(db, learningService, cfg, logger)
123
    // Create usage stats service
124
    usageStatsService := services.NewUsageStatsService(cfg, db, logger)
125
    aiService := services.NewAIService(cfg, logger, usageStatsService)
126
    workerService := services.NewWorkerServiceWithLogger(db, logger)
127

128
    // Get user by username
129
    user, err := userService.GetUserByUsername(ctx, *username)
130
    if err != nil {
131
        logger.Error(ctx, "Failed to get user", err)
132
        fmt.Fprintf(os.Stderr, "Failed to get user: %v\n", err)
133
        os.Exit(1)
134
    }
135
    if user == nil {
136
        logger.Error(ctx, "User not found", nil, map[string]interface{}{"username": *username})
137
        fmt.Fprintf(os.Stderr, "User not found: %s\n", *username)
138
        os.Exit(1)
139
        return
140
    }
141
    logger.Info(ctx, "Found user", map[string]interface{}{"username": user.Username, "user_id": user.ID})
142

143
    // Apply AI overrides if provided
144
    if *aiProvider != "" {
145
        user.AIProvider.String = *aiProvider
146
        user.AIProvider.Valid = true
147
        user.AIEnabled.Bool = true
148
        user.AIEnabled.Valid = true
149
    }
150
    if *aiModel != "" {
151
        user.AIModel.String = *aiModel
152
        user.AIModel.Valid = true
153
    }
154
    if *aiAPIKey != "" {
155
        // Set AI provider and API key if provided
156
        if *aiProvider != "" && *aiAPIKey != "" {
157
            if err := userService.SetUserAPIKey(ctx, user.ID, *aiProvider, *aiAPIKey); err != nil {
158
                logger.Error(ctx, "Failed to set API key", err)
159
                fmt.Fprintf(os.Stderr, "Failed to set API key: %v\n", err)
160
                os.Exit(1)
161
            }
162
        } else if *aiAPIKey != "" {
163
            // If only API key is provided, use the user's current AI provider
164
            if err := userService.SetUserAPIKey(ctx, user.ID, user.AIProvider.String, *aiAPIKey); err != nil {
165
                logger.Error(ctx, "Failed to set API key", err)
166
                fmt.Fprintf(os.Stderr, "Failed to set API key: %v\n", err)
167
                os.Exit(1)
168
            }
169
        }
170
    }
171

172
    // Check if user has AI enabled (after potential overrides)
173
    if !user.AIEnabled.Valid || !user.AIEnabled.Bool {
174
        logger.Warn(ctx, "User does not have AI enabled", map[string]interface{}{"username": user.Username, "user_id": user.ID})
175
        logger.Info(ctx, "You may want to enable AI for this user first or use --ai-provider flag")
176
    }
177

178
    // Determine language and level to use
179
    languageToUse := user.PreferredLanguage.String
180
    if *language != "" {
181
        languageToUse = *language
182
    }
183

184
    levelToUse := user.CurrentLevel.String
185
    if *level != "" {
186
        levelToUse = *level
187
    }
188

189
    // Validate that we have required settings
190
    if languageToUse == "" {
191
        logger.Error(ctx, "No language specified", nil, map[string]interface{}{"username": user.Username, "user_id": user.ID})
192
        fmt.Fprintln(os.Stderr, "Error: No language specified. User has no preferred language and --language flag not provided")
193
        os.Exit(1)
194
    }
195
    if levelToUse == "" {
196
        logger.Error(ctx, "No level specified", nil, map[string]interface{}{"username": user.Username, "user_id": user.ID})
197
        fmt.Fprintln(os.Stderr, "Error: No level specified. User has no current level and --level flag not provided")
198
        os.Exit(1)
199
    }
200

201
    // Print configuration
202
    fmt.Printf("=== CLI Worker Configuration ===\n")
203
    fmt.Printf("User: %s (ID: %d)\n", user.Username, user.ID)
204
    fmt.Printf("Language: %s\n", languageToUse)
205
    fmt.Printf("Level: %s\n", levelToUse)
206
    fmt.Printf("Question Type: %s\n", qType)
207
    fmt.Printf("Count: %d\n", *count)
208
    if *topic != "" {
209
        fmt.Printf("Topic: %s\n", *topic)
210
    }
211
    if user.AIProvider.Valid && user.AIProvider.String != "" {
212
        fmt.Printf("AI Provider: %s\n", user.AIProvider.String)
213
    }
214
    if user.AIModel.Valid && user.AIModel.String != "" {
215
        fmt.Printf("AI Model: %s\n", user.AIModel.String)
216
    }
217
    fmt.Printf("===============================\n\n")
218

219
    // Create email service
220
    emailService := services.CreateEmailService(cfg, logger)
221
    // Create daily question service
222
    dailyQuestionService := services.NewDailyQuestionService(db, logger, questionService, learningService)
223

224
    // Create story service
225
    storyService := services.NewStoryService(db, cfg, logger)
226

227
    // Create word of the day service
228
    wordOfTheDayService := services.NewWordOfTheDayService(db, logger)
229

230
    // Create translation cache repository
231
    translationCacheRepo := services.NewTranslationCacheRepository(db, logger)
232

233
    // Create a minimal worker instance for question generation
234
    workerInstance := worker.NewWorker(userService, questionService, aiService, learningService, workerService, dailyQuestionService, wordOfTheDayService, storyService, emailService, nil, translationCacheRepo, "cli", cfg, logger)
235

236
    // Create context with timeout
237
    ctx, cancel := context.WithTimeout(ctx, config.CLIWorkerTimeout)
238
    defer cancel()
239

240
    // Log CLI worker start with structured logging
241
    logger.Info(ctx, "CLI worker starting question generation", map[string]interface{}{
242
        "user_id":       user.ID,
243
        "username":      user.Username,
244
        "question_type": qType,
245
        "count":         *count,
246
        "language":      languageToUse,
247
        "level":         levelToUse,
248
    })
249

250
    // Generate questions
251
    fmt.Printf("Starting question generation...\n")
252
    startTime := time.Now()
253

254
    result, err := workerInstance.GenerateQuestionsForUser(ctx, user, languageToUse, levelToUse, qType, *count, *topic)
255

256
    duration := time.Since(startTime)
257

258
    if err != nil {
259
        fmt.Printf("\nâ Question generation failed after %v\n", duration)
260
        fmt.Printf("Error: %v\n", err)
261
        os.Exit(1)
262
    }
263

264
    fmt.Printf("\nâ Question generation completed successfully in %v\n", duration)
265
    fmt.Printf("Result: %s\n", result)
266
}
267

268
8x
func isValidLevel(level string, validLevels []string) bool {
269
8x
    for _, validLevel := range validLevels {
270
33x
        if strings.EqualFold(level, validLevel) {
271
6x
            return true
272
6x
        }
273
    }
274
2x
    return false
275
}
276

277
6x
func isValidLanguage(language string, validLanguages []string) bool {
278
6x
    for _, validLang := range validLanguages {
279
18x
        if strings.EqualFold(language, validLang) {
280
4x
            return true
281
4x
        }
282
    }
283
2x
    return false
284
}
285

286
func printUsage(cfg *config.Config) {
287
    if cfg == nil {
288
        fmt.Fprintf(os.Stderr, "Error: Configuration is missing or invalid.\n")
289
        return
290
    }
291
    fmt.Printf("Usage: cli-worker [flags]\n")
292
    fmt.Printf("Flags:\n")
293
    fmt.Printf("  -language string\tLanguage to generate questions for\n")
294
    fmt.Printf("  -level string\tLevel to generate questions for\n")
295
    fmt.Printf("  -type string\tQuestion type (vocabulary, fill_in_blank, qa, reading_comprehension)\n")
296
    fmt.Printf("  -count int\tNumber of questions to generate (default 1)\n")
297
    fmt.Printf("  -topic string\tTopic for question generation\n")
298
    fmt.Printf("  -provider string\tAI provider to use\n")
299
    fmt.Printf("  -model string\tAI model to use\n")
300
    fmt.Printf("  -help\tShow this help message\n\n")
301

302
    fmt.Printf("Valid levels: %s\n", strings.Join(cfg.GetAllLevels(), ", "))
303
    fmt.Printf("Valid languages: %s\n", strings.Join(cfg.GetLanguages(), ", "))
304
    if cfg.Providers != nil {
305
        providerNames := make([]string, 0, len(cfg.Providers))
306
        for _, p := range cfg.Providers {
307
            providerNames = append(providerNames, p.Code)
308
        }
309
        fmt.Printf("Valid providers: %s\n", strings.Join(providerNames, ", "))
310
    } else {
311
        fmt.Printf("Valid providers: \n")
312
    }
313
}
314


			
quizapp cmd reset-db
0.0%
Statements
0/76
main.go
0.0%
0/76
quizapp cmd reset-db main.go
0.0%
Statements
0/76
1
// Package main provides a small CLI utility to reset the application's
2
// database to a clean state. It is intended for local development and
3
// testing only and will permanently delete all data when run.
4
package main
5

6
import (
7
    "bufio"
8
    "context"
9
    "fmt"
10
    "os"
11
    "strings"
12

13
    "quizapp/internal/config"
14
    "quizapp/internal/database"
15
    "quizapp/internal/observability"
16
    "quizapp/internal/services"
17
)
18

19
// fatalIfErr logs the error with context and exits
20
func fatalIfErr(ctx context.Context, logger *observability.Logger, msg string, err error, fields map[string]interface{}) {
21
    logger.Error(ctx, msg, err, fields)
22
    os.Exit(1)
23
}
24

25
func main() {
26
    ctx := context.Background()
27

28
    // Load configuration first
29
    cfg, err := config.NewConfig()
30
    if err != nil {
31
        fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
32
        os.Exit(1)
33
    }
34

35
    // Setup observability (tracing/metrics/logging)
36
    _, _, logger, err := observability.SetupObservability(&cfg.OpenTelemetry, "reset-db")
37
    if err != nil {
38
        fmt.Fprintf(os.Stderr, "Failed to initialize observability: %v\n", err)
39
        os.Exit(1)
40
    }
41

42
    fmt.Println("âï  DATABASE RESET UTILITY âï")
43
    fmt.Println("=============================")
44
    fmt.Println("This will PERMANENTLY DELETE ALL DATA in the database!")
45
    fmt.Println("This includes:")
46
    fmt.Println("- All users (including admin)")
47
    fmt.Println("- All questions")
48
    fmt.Println("- All user responses")
49
    fmt.Println("- All performance metrics")
50
    fmt.Println("")
51

52
    logger.Info(ctx, "Attempting to reset the database", map[string]interface{}{"service": "reset-db"})
53

54
    if cfg.Database.URL == "" {
55
        fatalIfErr(ctx, logger, "Database URL is empty", nil, map[string]interface{}{"error": "Database URL is empty. Cannot proceed with reset."})
56
    }
57

58
    // Print database info
59
    fmt.Println("ð Database Information:")
60
    fmt.Printf("URL: %s\n", maskDatabaseURL(cfg.Database.URL))
61
    fmt.Println("")
62

63
    // Confirm with user
64
    if !confirmReset() {
65
        fmt.Println("Reset cancelled.")
66
        return
67
    }
68

69
    // Initialize database manager with logger
70
    dbManager := database.NewManager(logger)
71

72
    // Initialize database connection with configuration
73
    db, err := dbManager.InitDBWithConfig(cfg.Database)
74
    if err != nil {
75
        fatalIfErr(ctx, logger, "Failed to connect to database", err, map[string]interface{}{"db_url": cfg.Database.URL})
76
    }
77
    defer func() {
78
        if err := db.Close(); err != nil {
79
            logger.Warn(ctx, "Warning: failed to close database connection", map[string]interface{}{"error": err.Error(), "db_url": cfg.Database.URL})
80
        }
81
    }()
82

83
    // Initialize services
84
    userService := services.NewUserServiceWithLogger(db, cfg, logger)
85

86
    // Drop all tables
87
    fmt.Println("ðï  Dropping all tables...")
88
    logger.Info(ctx, "Dropping all tables", map[string]interface{}{"db_url": cfg.Database.URL, "service": "reset-db"})
89

90
    // For now, we'll just run migrations which will recreate the schema
91
    // In a real implementation, you might want to add a DropAllTables method to the database manager
92

93
    // Run migrations
94
    fmt.Println("ð Running database migrations...")
95
    logger.Info(ctx, "Running database migrations", map[string]interface{}{"db_url": cfg.Database.URL, "service": "reset-db"})
96

97
    if err := dbManager.RunMigrations(db); err != nil {
98
        fatalIfErr(ctx, logger, "Failed to run migrations", err, map[string]interface{}{"db_url": cfg.Database.URL})
99
    }
100

101
    fmt.Println("â Database migrations completed successfully!")
102
    logger.Info(ctx, "Database migrations completed successfully", map[string]interface{}{"db_url": cfg.Database.URL, "service": "reset-db"})
103

104
    // Recreate admin user immediately
105
    fmt.Printf("Recreating admin user '%s'...\n", cfg.Server.AdminUsername)
106
    logger.Info(ctx, "Recreating admin user", map[string]interface{}{"username": cfg.Server.AdminUsername, "service": "reset-db"})
107
    // Ensure admin user exists
108
    if err := userService.EnsureAdminUserExists(ctx, cfg.Server.AdminUsername, cfg.Server.AdminPassword); err != nil {
109
        fatalIfErr(ctx, logger, "Failed to ensure admin user exists", err, map[string]interface{}{"admin_username": cfg.Server.AdminUsername})
110
    }
111

112
    fmt.Println("â Admin user recreated successfully!")
113
    logger.Info(ctx, "Admin user recreated successfully", map[string]interface{}{"username": cfg.Server.AdminUsername, "service": "reset-db"})
114
    fmt.Println("")
115
    // Print admin credentials
116
    fmt.Printf("\nAdmin user credentials:\n")
117
    fmt.Printf("   Username: %s\n", cfg.Server.AdminUsername)
118
    fmt.Printf("   Password: %s\n", cfg.Server.AdminPassword)
119
    fmt.Println("")
120
    fmt.Println("â Database is now ready to use!")
121
    fmt.Println("- You can now start the server or use the existing running instance")
122
    fmt.Println("- Use the credentials above to log into the application")
123
}
124

125
func confirmReset() bool {
126
    reader := bufio.NewReader(os.Stdin)
127

128
    for {
129
        fmt.Print("Are you sure you want to reset the database? (type 'yes' to confirm): ")
130
        response, err := reader.ReadString('\n')
131
        if err != nil {
132
            fmt.Println("Error reading input:", err)
133
            continue
134
        }
135

136
        response = strings.TrimSpace(strings.ToLower(response))
137

138
        switch response {
139
        case "yes":
140
            return true
141
        case "no", "":
142
            return false
143
        default:
144
            fmt.Println("Please type 'yes' to confirm or 'no' to cancel.")
145
        }
146
    }
147
}
148

149
func maskDatabaseURL(url string) string {
150
    // Simple masking for display purposes
151
    if strings.Contains(url, "@") {
152
        parts := strings.Split(url, "@")
153
        if len(parts) == 2 {
154
            return "postgres://***:***@" + parts[1]
155
        }
156
    }
157
    return url
158
}
159


			
quizapp cmd server
0.0%
Statements
0/96
main.go
0.0%
0/96
quizapp cmd server main.go
0.0%
Statements
0/96
1
// Package main provides the main entry point for the quiz application backend server.
2
// It sets up the HTTP server, database connections, middleware, and API routes.
3
package main
4

5
import (
6
    "context"
7
    "fmt"
8
    "os"
9
    "os/signal"
10
    "syscall"
11
    "time"
12

13
    "quizapp/internal/config"
14
    "quizapp/internal/di"
15
    "quizapp/internal/handlers"
16
    "quizapp/internal/observability"
17
    contextutils "quizapp/internal/utils"
18

19
    "github.com/gin-gonic/gin"
20
)
21

22
// Application encapsulates the main application logic and can be tested
23
type Application struct {
24
    container di.ServiceContainerInterface
25
    router    *gin.Engine
26
}
27

28
// NewApplication creates a new application instance
29
func NewApplication(container di.ServiceContainerInterface) (*Application, error) {
30
    // Get services from container
31
    userService, err := container.GetUserService()
32
    if err != nil {
33
        return nil, contextutils.WrapError(err, "failed to get user service")
34
    }
35

36
    questionService, err := container.GetQuestionService()
37
    if err != nil {
38
        return nil, contextutils.WrapError(err, "failed to get question service")
39
    }
40

41
    learningService, err := container.GetLearningService()
42
    if err != nil {
43
        return nil, contextutils.WrapError(err, "failed to get learning service")
44
    }
45

46
    aiService, err := container.GetAIService()
47
    if err != nil {
48
        return nil, contextutils.WrapError(err, "failed to get AI service")
49
    }
50

51
    workerService, err := container.GetWorkerService()
52
    if err != nil {
53
        return nil, contextutils.WrapError(err, "failed to get worker service")
54
    }
55

56
    dailyQuestionService, err := container.GetDailyQuestionService()
57
    if err != nil {
58
        return nil, contextutils.WrapError(err, "failed to get daily question service")
59
    }
60

61
    storyService, err := container.GetStoryService()
62
    if err != nil {
63
        return nil, contextutils.WrapError(err, "failed to get story service")
64
    }
65

66
    oauthService, err := container.GetOAuthService()
67
    if err != nil {
68
        return nil, contextutils.WrapError(err, "failed to get OAuth service")
69
    }
70

71
    generationHintService, err := container.GetGenerationHintService()
72
    if err != nil {
73
        return nil, contextutils.WrapError(err, "failed to get generation hint service")
74
    }
75

76
    conversationService, err := container.GetConversationService()
77
    if err != nil {
78
        return nil, contextutils.WrapError(err, "failed to get conversation service")
79
    }
80

81
    translationService, err := container.GetTranslationService()
82
    if err != nil {
83
        return nil, contextutils.WrapError(err, "failed to get translation service")
84
    }
85

86
    snippetsService, err := container.GetSnippetsService()
87
    if err != nil {
88
        return nil, contextutils.WrapError(err, "failed to get snippets service")
89
    }
90

91
    usageStatsService, err := container.GetUsageStatsService()
92
    if err != nil {
93
        return nil, contextutils.WrapError(err, "failed to get usage stats service")
94
    }
95

96
    wordOfTheDayService, err := container.GetWordOfTheDayService()
97
    if err != nil {
98
        return nil, contextutils.WrapError(err, "failed to get word of the day service")
99
    }
100

101
    authAPIKeyService, err := container.GetAuthAPIKeyService()
102
    if err != nil {
103
        return nil, contextutils.WrapError(err, "failed to get auth API key service")
104
    }
105

106
    translationPracticeService, err := container.GetTranslationPracticeService()
107
    if err != nil {
108
        return nil, contextutils.WrapError(err, "failed to get translation practice service")
109
    }
110

111
    // Use the router factory
112
    router := handlers.NewRouter(
113
        container.GetConfig(),
114
        userService,
115
        questionService,
116
        learningService,
117
        aiService,
118
        workerService,
119
        dailyQuestionService,
120
        storyService,
121
        conversationService,
122
        oauthService,
123
        generationHintService,
124
        translationService,
125
        snippetsService,
126
        usageStatsService,
127
        wordOfTheDayService,
128
        authAPIKeyService,
129
        translationPracticeService,
130
        container.GetLogger(),
131
    )
132

133
    return &Application{
134
        container: container,
135
        router:    router,
136
    }, nil
137
}
138

139
// Run starts the application and returns an error if it fails to start
140
func (a *Application) Run(ctx context.Context, port string) error {
141
    // Start server in a goroutine
142
    serverErr := make(chan error, 1)
143
    go func() {
144
        if err := a.router.Run(":" + port); err != nil {
145
            serverErr <- err
146
        }
147
    }()
148

149
    // Wait for shutdown signal or server error
150
    select {
151
    case <-ctx.Done():
152
        return nil // Context cancelled, graceful shutdown
153
    case err := <-serverErr:
154
        return contextutils.WrapError(err, "server failed")
155
    }
156
}
157

158
// Shutdown gracefully shuts down the application
159
func (a *Application) Shutdown(ctx context.Context) error {
160
    return a.container.Shutdown(ctx)
161
}
162

163
func main() {
164
    ctx, cancel := context.WithCancel(context.Background())
165
    defer cancel()
166

167
    // Setup graceful shutdown
168
    shutdownCh := make(chan os.Signal, 1)
169
    signal.Notify(shutdownCh, syscall.SIGINT, syscall.SIGTERM)
170

171
    // Load configuration
172
    cfg, err := config.NewConfig()
173
    if err != nil {
174
        fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
175
        os.Exit(1)
176
    }
177

178
    // Setup observability (tracing/metrics/logging)
179
    _, _, logger, err := observability.SetupObservability(&cfg.OpenTelemetry, "quiz-backend")
180
    if err != nil {
181
        fmt.Fprintf(os.Stderr, "Failed to initialize observability: %v\n", err)
182
        os.Exit(1)
183
    }
184

185
    logger.Info(ctx, "Starting quiz backend service", map[string]interface{}{
186
        "port":     cfg.Server.Port,
187
        "logLevel": cfg.Server.LogLevel,
188
    })
189

190
    // Initialize dependency injection container
191
    container := di.NewServiceContainer(cfg, logger)
192

193
    // Initialize all services
194
    if err := container.Initialize(ctx); err != nil {
195
        logger.Error(ctx, "Failed to initialize services", err, nil)
196
        os.Exit(1)
197
    }
198

199
    // Ensure admin user exists
200
    if err := container.EnsureAdminUser(ctx); err != nil {
201
        logger.Error(ctx, "Failed to ensure admin user exists", err, map[string]interface{}{"admin_username": cfg.Server.AdminUsername})
202
        os.Exit(1)
203
    }
204

205
    // Create application instance
206
    app, err := NewApplication(container)
207
    if err != nil {
208
        logger.Error(ctx, "Failed to create application", err, nil)
209
        os.Exit(1)
210
    }
211

212
    // Start application in a goroutine
213
    appErr := make(chan error, 1)
214
    go func() {
215
        if err := app.Run(ctx, cfg.Server.Port); err != nil {
216
            appErr <- err
217
        }
218
    }()
219

220
    // Wait for shutdown signal or application error
221
    select {
222
    case <-shutdownCh:
223
        logger.Info(ctx, "Received shutdown signal, shutting down gracefully", nil)
224
    case err := <-appErr:
225
        logger.Error(ctx, "Application failed", err, nil)
226
        os.Exit(1)
227
    }
228

229
    // Graceful shutdown
230
    shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
231
    defer shutdownCancel()
232

233
    // Shutdown application
234
    if err := app.Shutdown(shutdownCtx); err != nil {
235
        logger.Error(ctx, "Error during application shutdown", err, nil)
236
        os.Exit(1)
237
    }
238

239
    logger.Info(ctx, "Shutdown completed successfully", nil)
240
}
241


			
quizapp cmd setup-test-db
0.0%
Statements
0/619
main.go
0.0%
0/619
quizapp cmd setup-test-db main.go
0.0%
Statements
0/619
1
// Package main provides a utility to set up the test database with initial data.
2
package main
3

4
import (
5
    "context"
6
    "database/sql"
7
    "encoding/json"
8
    "flag"
9
    "fmt"
10
    "os"
11
    "path/filepath"
12
    "strings"
13
    "time"
14

15
    "quizapp/internal/api"
16
    "quizapp/internal/config"
17
    "quizapp/internal/database"
18
    "quizapp/internal/models"
19
    "quizapp/internal/observability"
20
    "quizapp/internal/services"
21
    contextutils "quizapp/internal/utils"
22

23
    "go.uber.org/zap/zapcore"
24
    "gopkg.in/yaml.v3"
25
)
26

27
// TestUser represents a user in the test data files
28
type TestUser struct {
29
    Username          string   `yaml:"username"`
30
    Email             string   `yaml:"email"`
31
    Password          string   `yaml:"password"` // Special field for password creation
32
    PreferredLanguage string   `yaml:"preferred_language"`
33
    CurrentLevel      string   `yaml:"current_level"`
34
    AIProvider        string   `yaml:"ai_provider"`
35
    AIModel           string   `yaml:"ai_model"`
36
    AIAPIKey          string   `yaml:"ai_api_key"`
37
    Roles             []string `yaml:"roles"`
38
}
39

40
// TestUsers represents a collection of test users
41
type TestUsers struct {
42
    Users []TestUser `yaml:"users"`
43
}
44

45
// TestQuestions represents a collection of test questions
46
type TestQuestions struct {
47
    Questions []models.Question `yaml:"questions"`
48
}
49

50
// TestResponses represents a collection of test user responses
51
type TestResponses struct {
52
    UserResponses []struct {
53
        Username       string `yaml:"username"`
54
        QuestionIndex  int    `yaml:"question_index"`
55
        UserAnswer     string `yaml:"user_answer"`
56
        IsCorrect      bool   `yaml:"is_correct"`
57
        ResponseTimeMs int    `yaml:"response_time_ms"`
58
    } `yaml:"user_responses"`
59

60
    QuestionReports []struct {
61
        Username      string  `yaml:"username"`
62
        QuestionIndex int     `yaml:"question_index"`
63
        ReportReason  string  `yaml:"report_reason"`
64
        CreatedAt     *string `yaml:"created_at"`
65
    } `yaml:"question_reports"`
66
}
67

68
// TestAnalytics represents analytics test data
69
type TestAnalytics struct {
70
    PriorityScores []struct {
71
        Username         string  `yaml:"username"`
72
        QuestionIndex    int     `yaml:"question_index"`
73
        PriorityScore    float64 `yaml:"priority_score"`
74
        LastCalculatedAt string  `yaml:"last_calculated_at"`
75
    } `yaml:"priority_scores"`
76

77
    LearningPreferences []struct {
78
        Username             string  `yaml:"username"`
79
        FocusOnWeakAreas     bool    `yaml:"focus_on_weak_areas"`
80
        FreshQuestionRatio   float64 `yaml:"fresh_question_ratio"`
81
        WeakAreaBoost        float64 `yaml:"weak_area_boost"`
82
        KnownQuestionPenalty float64 `yaml:"known_question_penalty"`
83
        ReviewIntervalDays   int     `yaml:"review_interval_days"`
84
        DailyReminderEnabled bool    `yaml:"daily_reminder_enabled"`
85
    } `yaml:"learning_preferences"`
86

87
    PerformanceMetrics []struct {
88
        Username              string  `yaml:"username"`
89
        Topic                 string  `yaml:"topic"`
90
        Language              string  `yaml:"language"`
91
        Level                 string  `yaml:"level"`
92
        TotalAttempts         int     `yaml:"total_attempts"`
93
        CorrectAttempts       int     `yaml:"correct_attempts"`
94
        AverageResponseTimeMs float64 `yaml:"average_response_time_ms"`
95
    } `yaml:"performance_metrics"`
96

97
    UserQuestionMetadata []struct {
98
        Username        string  `yaml:"username"`
99
        QuestionIndex   int     `yaml:"question_index"`
100
        MarkedAsKnown   bool    `yaml:"marked_as_known"`
101
        MarkedAsKnownAt *string `yaml:"marked_as_known_at"`
102
    } `yaml:"user_question_metadata"`
103
}
104

105
// TestDailyAssignments represents the structure for daily question assignments in test data
106
type TestDailyAssignments struct {
107
    DailyAssignments []struct {
108
        Username           string `yaml:"username"`
109
        Date               string `yaml:"date"`
110
        QuestionIDs        []int  `yaml:"question_ids"`
111
        CompletedQuestions []int  `yaml:"completed_questions"`
112
    } `yaml:"daily_assignments"`
113
}
114

115
// TestMessageData represents message data for E2E tests
116
type TestMessageData struct {
117
    ID             string `json:"id"`
118
    ConversationID string `json:"conversation_id"`
119
    Role           string `json:"role"`
120
    Content        string `json:"content"`
121
    Bookmarked     bool   `json:"bookmarked"`
122
    QuestionID     *int   `json:"question_id,omitempty"`
123
    CreatedAt      string `json:"created_at"`
124
    UpdatedAt      string `json:"updated_at"`
125
}
126

127
// TestConversationData represents conversation data for E2E tests
128
type TestConversationData struct {
129
    ID       string            `json:"id"`
130
    Username string            `json:"username"`
131
    Title    string            `json:"title"`
132
    Messages []TestMessageData `json:"messages"`
133
}
134

135
// TestConversations represents a collection of test conversations
136
type TestConversations struct {
137
    Conversations []struct {
138
        Username string `yaml:"username"`
139
        Title    string `yaml:"title"`
140
        Messages []struct {
141
            Role       string `yaml:"role"`
142
            Content    string `yaml:"content"`
143
            QuestionID *int   `yaml:"question_id"`
144
        } `yaml:"messages"`
145
    } `yaml:"conversations"`
146
}
147

148
// TestStorySectionData represents section data for E2E tests
149
type TestStorySectionData struct {
150
    ID            int    `json:"id"`
151
    StoryID       int    `json:"story_id"`
152
    SectionNumber int    `json:"section_number"`
153
    Content       string `json:"content"`
154
    LanguageLevel string `json:"language_level"`
155
    WordCount     int    `json:"word_count"`
156
    GeneratedBy   string `json:"generated_by"`
157
}
158

159
// TestStoryData represents story data for E2E tests
160
type TestStoryData struct {
161
    ID       int                    `json:"id"`
162
    Username string                 `json:"username"`
163
    Title    string                 `json:"title"`
164
    Status   string                 `json:"status"`
165
    Sections []TestStorySectionData `json:"sections"`
166
}
167

168
// TestStories represents a collection of test stories
169
type TestStories struct {
170
    Stories []struct {
171
        Username              string  `yaml:"username"`
172
        Title                 string  `yaml:"title"`
173
        Language              string  `yaml:"language"`
174
        Subject               *string `yaml:"subject"`
175
        AuthorStyle           *string `yaml:"author_style"`
176
        TimePeriod            *string `yaml:"time_period"`
177
        Genre                 *string `yaml:"genre"`
178
        Tone                  *string `yaml:"tone"`
179
        CharacterNames        *string `yaml:"character_names"`
180
        CustomInstructions    *string `yaml:"custom_instructions"`
181
        SectionLengthOverride *string `yaml:"section_length_override"`
182
        Status                string  `yaml:"status"`
183
        IsCurrent             bool    `yaml:"is_current"`
184
        Sections              []struct {
185
            SectionNumber int    `yaml:"section_number"`
186
            Content       string `yaml:"content"`
187
            LanguageLevel string `yaml:"language_level"`
188
            WordCount     int    `yaml:"word_count"`
189
            GeneratedBy   string `yaml:"generated_by"`
190
            Questions     []struct {
191
                QuestionText       string   `yaml:"question_text"`
192
                Options            []string `yaml:"options"`
193
                CorrectAnswerIndex int      `yaml:"correct_answer_index"`
194
                Explanation        *string  `yaml:"explanation"`
195
            } `yaml:"questions"`
196
        } `yaml:"sections"`
197
    } `yaml:"stories"`
198
}
199

200
// TestSnippetData represents snippet data for E2E tests
201
type TestSnippetData struct {
202
    ID             int    `json:"id"`
203
    Username       string `json:"username"`
204
    OriginalText   string `json:"original_text"`
205
    TranslatedText string `json:"translated_text"`
206
    SourceLanguage string `json:"source_language"`
207
    TargetLanguage string `json:"target_language"`
208
}
209

210
// TestSnippets represents a collection of test snippets
211
type TestSnippets struct {
212
    Snippets []struct {
213
        Username        string  `yaml:"username"`
214
        OriginalText    string  `yaml:"original_text"`
215
        TranslatedText  string  `yaml:"translated_text"`
216
        SourceLanguage  string  `yaml:"source_language"`
217
        TargetLanguage  string  `yaml:"target_language"`
218
        Context         *string `yaml:"context"`
219
        DifficultyLevel string  `yaml:"difficulty_level"`
220
    } `yaml:"snippets"`
221
}
222

223
// TestFeedbackData represents feedback data for E2E tests
224
type TestFeedbackData struct {
225
    ID           int                    `json:"id"`
226
    Username     string                 `json:"username"`
227
    FeedbackText string                 `json:"feedback_text"`
228
    FeedbackType string                 `json:"feedback_type"`
229
    Status       string                 `json:"status"`
230
    ContextData  map[string]interface{} `json:"context_data"`
231
}
232

233
// TestFeedback represents a collection of test feedback
234
type TestFeedback struct {
235
    FeedbackReports []struct {
236
        Username     string                 `yaml:"username"`
237
        FeedbackText string                 `yaml:"feedback_text"`
238
        FeedbackType string                 `yaml:"feedback_type"`
239
        Status       string                 `yaml:"status"`
240
        ContextData  map[string]interface{} `yaml:"context_data"`
241
    } `yaml:"feedback_reports"`
242
}
243

244
func resetTestDatabase(databaseURL, testDB string, logger *observability.Logger) error {
245
    ctx := context.Background()
246

247
    // Create admin connection string by replacing the database name with 'postgres'
248
    // This connects to the admin database to drop/create the test database
249
    adminConnStr := strings.Replace(databaseURL, "/"+testDB+"?", "/postgres?", 1)
250
    if !strings.Contains(adminConnStr, "/postgres?") {
251
        // Handle case where there's no query string
252
        adminConnStr = strings.Replace(databaseURL, "/"+testDB, "/postgres", 1)
253
    }
254

255
    logger.Info(ctx, "Connecting to admin database", map[string]interface{}{"connection_string": adminConnStr})
256
    adminDB, err := sql.Open("postgres", adminConnStr)
257
    if err != nil {
258
        return contextutils.WrapErrorf(contextutils.ErrDatabaseConnection, "failed to connect to postgres database for drop/create: %v", err)
259
    }
260
    defer func() {
261
        if err := adminDB.Close(); err != nil {
262
            logger.Warn(ctx, "Warning: failed to close adminDB", map[string]interface{}{"error": err.Error()})
263
        }
264
    }()
265

266
    logger.Info(ctx, "Terminating connections to test DB", map[string]interface{}{"database": testDB})
267
    _, err = adminDB.Exec(fmt.Sprintf(`
268
        SELECT pg_terminate_backend(pid)
269
        FROM pg_stat_activity
270
        WHERE datname = '%s' AND pid <> pg_backend_pid();
271
    `, testDB))
272
    if err != nil {
273
        logger.Warn(ctx, "Warning: failed to terminate connections", map[string]interface{}{"error": err.Error()})
274
    }
275

276
    logger.Info(ctx, "Dropping test database", map[string]interface{}{"database": testDB})
277
    _, err = adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE);", testDB))
278
    if err != nil {
279
        return contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to drop test database: %v", err)
280
    }
281
    logger.Info(ctx, "Successfully dropped test database", map[string]interface{}{"database": testDB})
282

283
    logger.Info(ctx, "Creating test database", map[string]interface{}{"database": testDB})
284
    _, err = adminDB.Exec(fmt.Sprintf("CREATE DATABASE %s;", testDB))
285
    if err != nil {
286
        return contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to create test database: %v", err)
287
    }
288
    logger.Info(ctx, "Successfully created test database", map[string]interface{}{"database": testDB})
289

290
    logger.Info(ctx, "Test database reset complete")
291
    return nil
292
}
293

294
func main() {
295
    ctx := context.Background()
296

297
    // CLI flags
298
    verbose := flag.Bool("verbose", false, "enable verbose logging")
299
    flag.Parse()
300

301
    // Load configuration first
302
    cfg, err := config.NewConfig()
303
    if err != nil {
304
        fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
305
        os.Exit(1)
306
    }
307

308
    // Setup observability (tracing/metrics). Suppress logger creation here to avoid startup noise.
309
    originalLogging := cfg.OpenTelemetry.EnableLogging
310
    cfg.OpenTelemetry.EnableLogging = false
311
    _, _, _, err = observability.SetupObservability(&cfg.OpenTelemetry, "setup-test-db")
312
    if err != nil {
313
        fmt.Fprintf(os.Stderr, "Failed to initialize observability: %v\n", err)
314
        os.Exit(1)
315
    }
316

317
    // Create logger with level based on --verbose flag
318
    logLevel := zapcore.WarnLevel
319
    if *verbose {
320
        logLevel = zapcore.InfoLevel
321
    }
322
    // Restore config flag for logger construction (to allow OTLP exporter if enabled)
323
    cfg.OpenTelemetry.EnableLogging = originalLogging
324
    logger := observability.NewLoggerWithLevel(&cfg.OpenTelemetry, logLevel)
325

326
    // Get DB connection info from env or use defaults
327
    dbUser := "quiz_user"
328
    dbPassword := "quiz_password"
329
    dbHost := "localhost"
330
    dbPort := "5433"
331
    testDB := "quiz_test_db"
332

333
    // Allow override from DATABASE_URL
334
    databaseURL := os.Getenv("DATABASE_URL")
335
    if databaseURL == "" {
336
        databaseURL = fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", dbUser, dbPassword, dbHost, dbPort, testDB)
337
    }
338

339
    // Debug: Print the DATABASE_URL we're using
340
    logger.Info(ctx, "DATABASE_URL from environment", map[string]interface{}{"database_url": os.Getenv("DATABASE_URL")})
341
    logger.Info(ctx, "Using database URL", map[string]interface{}{"database_url": databaseURL})
342

343
    // --- Drop and recreate the test database ---
344
    if err := resetTestDatabase(databaseURL, testDB, logger); err != nil {
345
        logger.Error(ctx, "Failed to reset test database", err)
346
        os.Exit(1)
347
    }
348

349
    // Now connect to the new test database
350
    logger.Info(ctx, "Connecting to database", map[string]interface{}{"database_url": databaseURL})
351

352
    // Initialize database manager with logger
353
    dbManager := database.NewManager(logger)
354
    db, err := dbManager.InitDB(databaseURL)
355
    if err != nil {
356
        logger.Error(ctx, "Failed to initialize database", err)
357
        os.Exit(1)
358
    }
359
    defer func() {
360
        if err := db.Close(); err != nil {
361
            logger.Warn(ctx, "Warning: failed to close database", map[string]interface{}{"error": err.Error()})
362
        }
363
    }()
364

365
    // Get the root directory (backend is the working directory)
366
    rootDir, err := os.Getwd()
367
    if err != nil {
368
        logger.Error(ctx, "Failed to get working directory", err)
369
        os.Exit(1)
370
    }
371

372
    // Apply schema from schema.sql
373
    schemaPath := filepath.Join(rootDir, "..", "schema.sql")
374
    if err := applySchema(db, schemaPath, rootDir, logger); err != nil {
375
        logger.Error(ctx, "Failed to apply schema", err)
376
        os.Exit(1)
377
    }
378

379
    // Initialize services
380
    userService := services.NewUserServiceWithLogger(db, cfg, logger)
381
    learningService := services.NewLearningServiceWithLogger(db, cfg, logger)
382
    // Create question service
383
    questionService := services.NewQuestionServiceWithLogger(db, learningService, cfg, logger)
384

385
    // Ensure admin user exists
386
    if err := userService.EnsureAdminUserExists(ctx, "admin", "password"); err != nil {
387
        logger.Error(ctx, "Failed to ensure admin user exists", err)
388
        os.Exit(1)
389
    }
390

391
    // Load and insert test data
392
    users, err := setupTestData(ctx, rootDir, userService, questionService, learningService, db, logger)
393
    if err != nil {
394
        logger.Error(ctx, "Failed to setup test data", err)
395
        os.Exit(1)
396
    }
397

398
    // Output user data to JSON file for E2E tests
399
    if err := outputUserDataForTests(users, rootDir, logger); err != nil {
400
        logger.Error(ctx, "Failed to output user data for tests", err)
401
        os.Exit(1)
402
    }
403

404
    // Output roles data to JSON file for E2E tests
405
    if err := outputRolesDataForTests(db, rootDir, logger); err != nil {
406
        logger.Error(ctx, "Failed to output roles data for tests", err)
407
        os.Exit(1)
408
    }
409

410
    logger.Info(ctx, "Test database created successfully")
411
}
412

413
func applySchema(db *sql.DB, schemaPath, _ string, logger *observability.Logger) error {
414
    ctx := context.Background()
415

416
    // Apply the schema (database is already empty after resetTestDatabase)
417
    logger.Info(ctx, "Applying schema")
418
    schemaSQL, err := os.ReadFile(schemaPath)
419
    if err != nil {
420
        return contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to read schema file: %v", err)
421
    }
422

423
    if _, err := db.Exec(string(schemaSQL)); err != nil {
424
        return contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to execute schema: %v", err)
425
    }
426

427
    // Priority system tables are already included in the main schema.sql
428
    // No additional migration needed
429
    logger.Info(ctx, "Priority system tables already included in main schema")
430

431
    return nil
432
}
433

434
func setupTestData(ctx context.Context, rootDir string, userService *services.UserService, questionService *services.QuestionService, learningService *services.LearningService, db *sql.DB, logger *observability.Logger) (map[string]*models.User, error) {
435
    dataDir := filepath.Join(rootDir, "data")
436

437
    // 1. Load and create users
438
    users, err := loadAndCreateUsers(ctx, filepath.Join(dataDir, "test_users.yaml"), userService, logger)
439
    if err != nil {
440
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup users: %v", err)
441
    }
442

443
    // 2. Load and create questions
444
    questions, err := loadAndCreateQuestions(ctx, filepath.Join(dataDir, "test_questions.yaml"), questionService, users, logger)
445
    if err != nil {
446
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup questions: %v", err)
447
    }
448

449
    // 3. Load and create user responses
450
    if err := loadAndCreateResponses(ctx, filepath.Join(dataDir, "test_responses.yaml"), users, questions, learningService, logger); err != nil {
451
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup responses: %v", err)
452
    }
453

454
    // 4. Load and create question reports
455
    if err := loadAndCreateQuestionReports(ctx, filepath.Join(dataDir, "test_responses.yaml"), users, questions, db, logger); err != nil {
456
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup question reports: %v", err)
457
    }
458

459
    // 5. Load and create analytics data
460
    if err := loadAndCreateAnalytics(ctx, filepath.Join(dataDir, "test_analytics.yaml"), users, questions, learningService, db, logger); err != nil {
461
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup analytics: %v", err)
462
    }
463

464
    // 6. Load and create daily assignments
465
    if err := loadAndCreateDailyAssignments(ctx, filepath.Join(dataDir, "test_daily_assignments.yaml"), users, questions, db, logger); err != nil {
466
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup daily assignments: %v", err)
467
    }
468

469
    // 7. Load and create stories
470
    stories, err := loadAndCreateStories(ctx, filepath.Join(dataDir, "test_stories.yaml"), users, db, logger)
471
    if err != nil {
472
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup stories: %v", err)
473
    }
474

475
    // Output story data for E2E tests
476
    if err := outputStoryDataForTests(stories, rootDir, logger); err != nil {
477
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to output story data: %v", err)
478
    }
479

480
    // 8. Load and create snippets
481
    snippets, err := loadAndCreateSnippets(ctx, filepath.Join(dataDir, "test_snippets.yaml"), users, db, logger)
482
    if err != nil {
483
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup snippets: %v", err)
484
    }
485

486
    // Output snippet data for E2E tests
487
    if err := outputSnippetDataForTests(snippets, rootDir, logger); err != nil {
488
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to output snippet data: %v", err)
489
    }
490

491
    // 9. Load and create conversations
492
    conversations, err := loadAndCreateConversations(ctx, filepath.Join(dataDir, "test_conversations.yaml"), users, db, logger)
493
    if err != nil {
494
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup conversations: %v", err)
495
    }
496

497
    // Output conversation data for E2E tests
498
    if err := outputConversationDataForTests(conversations, rootDir, logger); err != nil {
499
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to output conversation data: %v", err)
500
    }
501

502
    // 10. Load and create feedback reports
503
    feedback, err := loadAndCreateFeedback(ctx, filepath.Join(dataDir, "test_feedback.yaml"), users, db, logger)
504
    if err != nil {
505
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup feedback: %v", err)
506
    }
507

508
    // Output feedback data for E2E tests
509
    if err := outputFeedbackDataForTests(feedback, rootDir, logger); err != nil {
510
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to output feedback data: %v", err)
511
    }
512

513
    // 11. Create API Keys for test users
514
    if err := createAndOutputAPIKeysForTests(ctx, users, db, rootDir, logger); err != nil {
515
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup api keys: %v", err)
516
    }
517

518
    // 12. Seed translation practice sentences and sessions (non-AI, deterministic)
519
    if err := seedTranslationPracticeData(ctx, users, db, logger); err != nil {
520
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to seed translation practice data: %v", err)
521
    }
522

523
    return users, nil
524
}
525

526
// TestAPIKeyData represents API key data for E2E tests (non-sensitive)
527
type TestAPIKeyData struct {
528
    ID              int       `json:"id"`
529
    Username        string    `json:"username"`
530
    KeyName         string    `json:"key_name"`
531
    KeyPrefix       string    `json:"key_prefix"`
532
    PermissionLevel string    `json:"permission_level"`
533
    CreatedAt       time.Time `json:"created_at"`
534
}
535

536
// createAndOutputAPIKeysForTests creates API keys for selected users and writes a JSON artifact for tests
537
func createAndOutputAPIKeysForTests(ctx context.Context, users map[string]*models.User, db *sql.DB, rootDir string, logger *observability.Logger) error {
538
    // Initialize service
539
    apiKeyService := services.NewAuthAPIKeyService(db, logger)
540

541
    // Strategy:
542
    // - apitestuser: 2 keys (readonly, full)
543
    // - apitestadmin: 2 keys (readonly, full)
544
    // - others: 1 readonly key
545

546
    // Helper to create a key and capture minimal info
547
    create := func(username string, userID int, keyName, perm string) (*TestAPIKeyData, error) {
548
        key, _, err := apiKeyService.CreateAPIKey(ctx, userID, keyName, perm)
549
        if err != nil {
550
            return nil, err
551
        }
552
        return &TestAPIKeyData{
553
            ID:              key.ID,
554
            Username:        username,
555
            KeyName:         key.KeyName,
556
            KeyPrefix:       key.KeyPrefix,
557
            PermissionLevel: key.PermissionLevel,
558
            CreatedAt:       key.CreatedAt,
559
        }, nil
560
    }
561

562
    apiKeys := make(map[string]TestAPIKeyData)
563

564
    for username, user := range users {
565
        if username == "apitestuser" || username == "apitestadmin" {
566
            if d, err := create(username, user.ID, "test_key_readonly", string(models.PermissionLevelReadonly)); err == nil {
567
                apiKeys[fmt.Sprintf("%s_ro", username)] = *d
568
            } else {
569
                return contextutils.WrapErrorf(err, "failed creating readonly api key for %s", username)
570
            }
571
            if d, err := create(username, user.ID, "test_key_full", string(models.PermissionLevelFull)); err == nil {
572
                apiKeys[fmt.Sprintf("%s_full", username)] = *d
573
            } else {
574
                return contextutils.WrapErrorf(err, "failed creating full api key for %s", username)
575
            }
576
        } else {
577
            if d, err := create(username, user.ID, "test_key_readonly", string(models.PermissionLevelReadonly)); err == nil {
578
                apiKeys[fmt.Sprintf("%s_ro", username)] = *d
579
            } else {
580
                return contextutils.WrapErrorf(err, "failed creating readonly api key for %s", username)
581
            }
582
        }
583
    }
584

585
    // Write to JSON file in the frontend/tests directory
586
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-api-keys.json")
587
    outputDir := filepath.Dir(outputPath)
588
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
589
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
590
    }
591
    jsonData, err := json.MarshalIndent(apiKeys, "", "  ")
592
    if err != nil {
593
        return contextutils.WrapErrorf(err, "failed to marshal api keys data to JSON")
594
    }
595
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
596
        return contextutils.WrapErrorf(err, "failed to write api keys data to file: %s", outputPath)
597
    }
598

599
    logger.Info(context.Background(), "Output API keys data for E2E tests", map[string]interface{}{
600
        "file_path":  outputPath,
601
        "keys_count": len(apiKeys),
602
    })
603

604
    return nil
605
}
606

607
func loadAndCreateUsers(ctx context.Context, filePath string, userService *services.UserService, logger *observability.Logger) (result0 map[string]*models.User, err error) {
608
    data, err := os.ReadFile(filePath)
609
    if err != nil {
610
        return nil, err
611
    }
612

613
    var testUsers TestUsers
614
    if err := yaml.Unmarshal(data, &testUsers); err != nil {
615
        return nil, err
616
    }
617

618
    users := make(map[string]*models.User)
619
    for _, testUser := range testUsers.Users {
620
        // Create user with email and timezone
621
        user, err := userService.CreateUserWithEmailAndTimezone(
622
            ctx,
623
            testUser.Username,
624
            testUser.Email,
625
            "UTC", // Default timezone for test users
626
            testUser.PreferredLanguage,
627
            testUser.CurrentLevel,
628
        )
629
        if err != nil {
630
            return nil, contextutils.WrapErrorf(err, "failed to create user %s", testUser.Username)
631
        }
632

633
        // Set password separately since CreateUserWithEmailAndTimezone doesn't set password
634
        if err := userService.UpdateUserPassword(ctx, user.ID, testUser.Password); err != nil {
635
            return nil, contextutils.WrapErrorf(err, "failed to set password for user %s", testUser.Username)
636
        }
637

638
        // Update additional settings
639
        settings := &models.UserSettings{
640
            Language:   testUser.PreferredLanguage,
641
            Level:      testUser.CurrentLevel,
642
            AIProvider: testUser.AIProvider,
643
            AIModel:    testUser.AIModel,
644
            AIAPIKey:   testUser.AIAPIKey,
645
            AIEnabled:  testUser.AIProvider != "", // Enable AI if provider is set
646
        }
647

648
        if err := userService.UpdateUserSettings(ctx, user.ID, settings); err != nil {
649
            return nil, contextutils.WrapErrorf(err, "failed to update settings for user %s", testUser.Username)
650
        }
651

652
        // Assign roles from YAML configuration
653
        for _, roleName := range testUser.Roles {
654
            err = userService.AssignRoleByName(ctx, user.ID, roleName)
655
            if err != nil {
656
                logger.Warn(ctx, "Failed to assign role to user", map[string]interface{}{
657
                    "username": testUser.Username,
658
                    "role":     roleName,
659
                    "error":    err.Error(),
660
                })
661
            } else {
662
                logger.Info(ctx, "Assigned role to user", map[string]interface{}{
663
                    "username": testUser.Username,
664
                    "role":     roleName,
665
                    "user_id":  user.ID,
666
                })
667
            }
668
        }
669

670
        users[testUser.Username] = user
671
    }
672

673
    return users, nil
674
}
675

676
func loadAndCreateQuestions(ctx context.Context, filePath string, questionService *services.QuestionService, users map[string]*models.User, _ *observability.Logger) (result0 []*models.Question, err error) {
677
    data, err := os.ReadFile(filePath)
678
    if err != nil {
679
        return nil, err
680
    }
681

682
    var testQuestions TestQuestions
683
    if err := yaml.Unmarshal(data, &testQuestions); err != nil {
684
        return nil, err
685
    }
686

687
    var questions []*models.Question
688
    for i, question := range testQuestions.Questions {
689
        // Set the created time since it's not in YAML
690
        question.CreatedAt = time.Now()
691

692
        // Validate question content before saving (SaveQuestion also validates, but this gives better error context)
693
        if err := contextutils.ValidateQuestionContent(question.Content, i+1); err != nil {
694
            return nil, contextutils.WrapErrorf(err, "question at index %d (will be ID %d) has invalid content", i, i+1)
695
        }
696

697
        // Get the users this question should be assigned to
698
        questionUsers := question.Users
699
        var assignedUserIDs []int
700
        if len(questionUsers) == 0 {
701
            // Fallback to round-robin if no users specified
702
            for _, user := range users {
703
                assignedUserIDs = append(assignedUserIDs, user.ID)
704
            }
705
            if len(assignedUserIDs) == 0 {
706
                return nil, contextutils.ErrorWithContextf("no users available to assign questions to")
707
            }
708
            // Assign to one user in round-robin
709
            assignedUserIDs = []int{assignedUserIDs[i%len(assignedUserIDs)]}
710
        } else {
711
            for _, username := range questionUsers {
712
                user, exists := users[username]
713
                if !exists {
714
                    return nil, contextutils.ErrorWithContextf("user not found: %s", username)
715
                }
716
                assignedUserIDs = append(assignedUserIDs, user.ID)
717
            }
718
        }
719

720
        if err := questionService.SaveQuestion(ctx, &question); err != nil {
721
            return nil, contextutils.WrapErrorf(err, "failed to save question at index %d (will be ID %d)", i, i+1)
722
        }
723

724
        for _, userID := range assignedUserIDs {
725
            if err := questionService.AssignQuestionToUser(ctx, question.ID, userID); err != nil {
726
                return nil, contextutils.WrapErrorf(err, "failed to assign question %d to user %d", question.ID, userID)
727
            }
728
        }
729

730
        questions = append(questions, &question)
731
    }
732

733
    return questions, nil
734
}
735

736
func loadAndCreateResponses(_ context.Context, filePath string, users map[string]*models.User, questions []*models.Question, learningService *services.LearningService, _ *observability.Logger) error {
737
    data, err := os.ReadFile(filePath)
738
    if err != nil {
739
        return err
740
    }
741

742
    var testResponses TestResponses
743
    if err := yaml.Unmarshal(data, &testResponses); err != nil {
744
        return err
745
    }
746

747
    for i, responseData := range testResponses.UserResponses {
748
        user, exists := users[responseData.Username]
749
        if !exists {
750
            return contextutils.ErrorWithContextf("user not found: %s", responseData.Username)
751
        }
752

753
        if responseData.QuestionIndex >= len(questions) {
754
            return contextutils.ErrorWithContextf("question index out of range: %d", responseData.QuestionIndex)
755
        }
756

757
        question := questions[responseData.QuestionIndex]
758

759
        // Use RecordAnswerWithPriority to ensure priority scores are calculated
760
        if err := learningService.RecordAnswerWithPriority(
761
            context.Background(),
762
            user.ID,
763
            question.ID,
764
            0, // Use index 0 for test data
765
            responseData.IsCorrect,
766
            responseData.ResponseTimeMs,
767
        ); err != nil {
768
            return contextutils.WrapErrorf(err, "failed to record response %d", i)
769
        }
770

771
    }
772

773
    return nil
774
}
775

776
func loadAndCreateQuestionReports(_ context.Context, filePath string, users map[string]*models.User, questions []*models.Question, db *sql.DB, _ *observability.Logger) error {
777
    data, err := os.ReadFile(filePath)
778
    if err != nil {
779
        return contextutils.WrapError(err, "failed to read responses file")
780
    }
781

782
    var testResponses TestResponses
783
    if err := yaml.Unmarshal(data, &testResponses); err != nil {
784
        return contextutils.WrapError(err, "failed to parse responses data")
785
    }
786

787
    // Load question reports
788
    for i, reportData := range testResponses.QuestionReports {
789
        user, exists := users[reportData.Username]
790
        if !exists {
791
            return contextutils.ErrorWithContextf("user not found for question report: %s", reportData.Username)
792
        }
793

794
        if reportData.QuestionIndex >= len(questions) {
795
            return contextutils.ErrorWithContextf("question index out of range for question report: %d", reportData.QuestionIndex)
796
        }
797

798
        question := questions[reportData.QuestionIndex]
799

800
        // Parse the timestamp if provided, otherwise use current time
801
        var createdAt time.Time
802
        if reportData.CreatedAt != nil {
803
            var err error
804
            createdAt, err = time.Parse(time.RFC3339, *reportData.CreatedAt)
805
            if err != nil {
806
                return contextutils.ErrorWithContextf("invalid timestamp format for question report: %s", *reportData.CreatedAt)
807
            }
808
        } else {
809
            createdAt = time.Now()
810
        }
811

812
        // Insert question report directly into database
813
        _, err := db.Exec(`
814
            INSERT INTO question_reports (question_id, reported_by_user_id, report_reason, created_at)
815
            VALUES ($1, $2, $3, $4)
816
            ON CONFLICT (question_id, reported_by_user_id) DO NOTHING
817
        `, question.ID, user.ID, reportData.ReportReason, createdAt)
818
        if err != nil {
819
            return contextutils.WrapErrorf(err, "failed to insert question report %d", i)
820
        }
821
    }
822

823
    return nil
824
}
825

826
func loadAndCreateAnalytics(ctx context.Context, filePath string, users map[string]*models.User, questions []*models.Question, learningService *services.LearningService, db *sql.DB, logger *observability.Logger) error {
827
    data, err := os.ReadFile(filePath)
828
    if err != nil {
829
        // Analytics file is optional, so just return if it doesn't exist
830
        logger.Warn(ctx, "Analytics file not found", map[string]interface{}{"file_path": filePath})
831
        return nil
832
    }
833

834
    var testAnalytics TestAnalytics
835
    if err := yaml.Unmarshal(data, &testAnalytics); err != nil {
836
        return contextutils.WrapError(err, "failed to parse analytics data")
837
    }
838

839
    // Load priority scores
840
    for _, priorityData := range testAnalytics.PriorityScores {
841
        user, exists := users[priorityData.Username]
842
        if !exists {
843
            return contextutils.ErrorWithContextf("user not found for priority score: %s", priorityData.Username)
844
        }
845

846
        if priorityData.QuestionIndex >= len(questions) {
847
            return contextutils.ErrorWithContextf("question index out of range for priority score: %d", priorityData.QuestionIndex)
848
        }
849

850
        question := questions[priorityData.QuestionIndex]
851

852
        // Parse the timestamp
853
        lastCalculatedAt, err := time.Parse(time.RFC3339, priorityData.LastCalculatedAt)
854
        if err != nil {
855
            return contextutils.ErrorWithContextf("invalid timestamp format for priority score: %s", priorityData.LastCalculatedAt)
856
        }
857

858
        // Insert priority score directly into database
859
        _, err = db.Exec(`
860
            INSERT INTO question_priority_scores (user_id, question_id, priority_score, last_calculated_at, created_at, updated_at)
861
            VALUES ($1, $2, $3, $4, NOW(), NOW())
862
            ON CONFLICT (user_id, question_id) DO UPDATE SET
863
                priority_score = EXCLUDED.priority_score,
864
                last_calculated_at = EXCLUDED.last_calculated_at,
865
                updated_at = NOW()
866
        `, user.ID, question.ID, priorityData.PriorityScore, lastCalculatedAt)
867
        if err != nil {
868
            return contextutils.WrapError(err, "failed to insert priority score")
869
        }
870

871
    }
872

873
    // Load learning preferences
874
    for _, prefData := range testAnalytics.LearningPreferences {
875
        user, exists := users[prefData.Username]
876
        if !exists {
877
            return contextutils.ErrorWithContextf("user not found for learning preferences: %s", prefData.Username)
878
        }
879

880
        // Ensure daily_goal is present and valid. The schema enforces daily_goal > 0
881
        // so default to the service's default if not provided or invalid.
882
        dailyGoal := 0
883
        // Try to parse a daily_goal field if it exists in the YAML by checking for a map
884
        // fallback: the YAML struct doesn't include daily_goal currently; use default
885
        // from the LearningService defaults.
886
        // We'll fetch defaults from service to avoid duplicating magic numbers.
887
        defaultPrefs := learningService.GetDefaultLearningPreferences()
888
        if dailyGoal <= 0 {
889
            dailyGoal = defaultPrefs.DailyGoal
890
        }
891

892
        prefs := &models.UserLearningPreferences{
893
            UserID:               user.ID,
894
            FocusOnWeakAreas:     prefData.FocusOnWeakAreas,
895
            FreshQuestionRatio:   prefData.FreshQuestionRatio,
896
            WeakAreaBoost:        prefData.WeakAreaBoost,
897
            KnownQuestionPenalty: prefData.KnownQuestionPenalty,
898
            ReviewIntervalDays:   prefData.ReviewIntervalDays,
899
            DailyReminderEnabled: prefData.DailyReminderEnabled,
900
            DailyGoal:            dailyGoal,
901
        }
902

903
        if _, err := learningService.UpdateUserLearningPreferences(ctx, user.ID, prefs); err != nil {
904
            return contextutils.WrapErrorf(err, "failed to update learning preferences for user %s", prefData.Username)
905
        }
906

907
    }
908

909
    // Load performance metrics
910
    for _, metricData := range testAnalytics.PerformanceMetrics {
911
        user, exists := users[metricData.Username]
912
        if !exists {
913
            return contextutils.ErrorWithContextf("user not found for performance metrics: %s", metricData.Username)
914
        }
915

916
        // Insert performance metric directly into database
917
        _, err := db.Exec(`
918
            INSERT INTO performance_metrics (user_id, topic, language, level, total_attempts, correct_attempts, average_response_time_ms, last_updated)
919
            VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
920
            ON CONFLICT (user_id, topic, language, level) DO UPDATE SET
921
                total_attempts = EXCLUDED.total_attempts,
922
                correct_attempts = EXCLUDED.correct_attempts,
923
                average_response_time_ms = EXCLUDED.average_response_time_ms,
924
                last_updated = NOW()
925
        `, user.ID, metricData.Topic, metricData.Language, metricData.Level,
926
            metricData.TotalAttempts, metricData.CorrectAttempts, metricData.AverageResponseTimeMs)
927
        if err != nil {
928
            return contextutils.WrapError(err, "failed to insert performance metric")
929
        }
930

931
    }
932

933
    // Load user question metadata (marked as known)
934
    for _, metadata := range testAnalytics.UserQuestionMetadata {
935
        user, exists := users[metadata.Username]
936
        if !exists {
937
            return contextutils.ErrorWithContextf("user not found for question metadata: %s", metadata.Username)
938
        }
939

940
        if metadata.QuestionIndex >= len(questions) {
941
            return contextutils.ErrorWithContextf("question index out of range for metadata: %d", metadata.QuestionIndex)
942
        }
943

944
        question := questions[metadata.QuestionIndex]
945

946
        if metadata.MarkedAsKnown {
947
            var markedAt time.Time
948
            if metadata.MarkedAsKnownAt != nil {
949
                var err error
950
                markedAt, err = time.Parse(time.RFC3339, *metadata.MarkedAsKnownAt)
951
                if err != nil {
952
                    return contextutils.ErrorWithContextf("invalid timestamp format for marked as known: %s", *metadata.MarkedAsKnownAt)
953
                }
954
            } else {
955
                markedAt = time.Now()
956
            }
957

958
            // Insert into user_question_metadata table
959
            _, err := db.Exec(`
960
                INSERT INTO user_question_metadata (user_id, question_id, marked_as_known, marked_as_known_at, created_at, updated_at)
961
                VALUES ($1, $2, $3, $4, NOW(), NOW())
962
                ON CONFLICT (user_id, question_id) DO UPDATE SET
963
                    marked_as_known = EXCLUDED.marked_as_known,
964
                    marked_as_known_at = EXCLUDED.marked_as_known_at,
965
                    updated_at = NOW()
966
            `, user.ID, question.ID, metadata.MarkedAsKnown, markedAt)
967
            if err != nil {
968
                return contextutils.WrapError(err, "failed to insert question metadata")
969
            }
970

971
        }
972
    }
973

974
    return nil
975
}
976

977
func loadAndCreateDailyAssignments(ctx context.Context, filePath string, users map[string]*models.User, questions []*models.Question, db *sql.DB, logger *observability.Logger) error {
978
    data, err := os.ReadFile(filePath)
979
    if err != nil {
980
        // File doesn't exist, skip daily assignments
981
        logger.Info(ctx, "Daily assignments file not found, skipping", map[string]interface{}{
982
            "file_path": filePath,
983
        })
984
        return nil
985
    }
986

987
    var testDailyAssignments TestDailyAssignments
988
    if err := yaml.Unmarshal(data, &testDailyAssignments); err != nil {
989
        return err
990
    }
991

992
    for _, assignmentData := range testDailyAssignments.DailyAssignments {
993
        user, exists := users[assignmentData.Username]
994
        if !exists {
995
            logger.Warn(ctx, "User not found for daily assignment", map[string]interface{}{
996
                "username": assignmentData.Username,
997
            })
998
            continue
999
        }
1000

1001
        // Parse the date
1002
        date, err := time.Parse("2006-01-02", assignmentData.Date)
1003
        if err != nil {
1004
            logger.Warn(ctx, "Invalid date format for daily assignment", map[string]interface{}{
1005
                "username": assignmentData.Username,
1006
                "date":     assignmentData.Date,
1007
            })
1008
            continue
1009
        }
1010

1011
        // Create a map of completed questions for quick lookup
1012
        completedQuestions := make(map[int]bool)
1013
        for _, qID := range assignmentData.CompletedQuestions {
1014
            completedQuestions[qID] = true
1015
        }
1016

1017
        // Assign questions to the user for the specific date
1018
        for _, questionID := range assignmentData.QuestionIDs {
1019
            // Check if question exists
1020
            if questionID <= 0 || questionID > len(questions) {
1021
                logger.Warn(ctx, "Question ID out of range for daily assignment", map[string]interface{}{
1022
                    "username":    assignmentData.Username,
1023
                    "date":        assignmentData.Date,
1024
                    "question_id": questionID,
1025
                })
1026
                continue
1027
            }
1028

1029
            question := questions[questionID-1] // Convert to 0-based index
1030

1031
            // Ensure we don't violate unique constraint by removing any existing assignment for the same
1032
            // (user_id, question_id, assignment_date) tuple before inserting. This avoids relying on
1033
            // ON CONFLICT which requires the constraint to be present in some test DB states.
1034
            deleteQuery := `DELETE FROM daily_question_assignments WHERE user_id = $1 AND question_id = $2 AND assignment_date = $3`
1035
            if _, err := db.ExecContext(ctx, deleteQuery, user.ID, question.ID, date); err != nil {
1036
                logger.Error(ctx, "Failed to delete existing daily assignment", err, map[string]interface{}{
1037
                    "username":    assignmentData.Username,
1038
                    "date":        assignmentData.Date,
1039
                    "question_id": questionID,
1040
                })
1041
                return contextutils.WrapErrorf(err, "failed to delete existing daily assignment for user %s, question %d", assignmentData.Username, questionID)
1042
            }
1043

1044
            // Insert the assignment directly into the database
1045
            query := `
1046
                INSERT INTO daily_question_assignments (user_id, question_id, assignment_date, is_completed, completed_at)
1047
                VALUES ($1, $2, $3, $4, $5)
1048
            `
1049

1050
            isCompleted := completedQuestions[questionID]
1051
            var completedAt *time.Time
1052
            if isCompleted {
1053
                now := time.Now()
1054
                completedAt = &now
1055
            }
1056

1057
            if _, err := db.ExecContext(ctx, query, user.ID, question.ID, date, isCompleted, completedAt); err != nil {
1058
                logger.Error(ctx, "Failed to create daily assignment", err, map[string]interface{}{
1059
                    "username":    assignmentData.Username,
1060
                    "date":        assignmentData.Date,
1061
                    "question_id": questionID,
1062
                })
1063
                return contextutils.WrapErrorf(err, "failed to create daily assignment for user %s, question %d", assignmentData.Username, questionID)
1064
            }
1065
        }
1066

1067
        logger.Info(ctx, "Created daily assignments", map[string]interface{}{
1068
            "username": assignmentData.Username,
1069
            "date":     assignmentData.Date,
1070
            "count":    len(assignmentData.QuestionIDs),
1071
        })
1072
    }
1073

1074
    return nil
1075
}
1076

1077
func loadAndCreateStories(ctx context.Context, filePath string, users map[string]*models.User, db *sql.DB, logger *observability.Logger) (map[string]TestStoryData, error) {
1078
    stories := make(map[string]TestStoryData)
1079
    data, err := os.ReadFile(filePath)
1080
    if err != nil {
1081
        // Stories file is optional, so just return if it doesn't exist
1082
        logger.Info(ctx, "Stories file not found, skipping", map[string]interface{}{
1083
            "file_path": filePath,
1084
        })
1085
        return stories, nil
1086
    }
1087

1088
    var testStories TestStories
1089
    if err := yaml.Unmarshal(data, &testStories); err != nil {
1090
        return stories, contextutils.WrapError(err, "failed to parse stories data")
1091
    }
1092

1093
    for i, storyData := range testStories.Stories {
1094
        user, exists := users[storyData.Username]
1095
        if !exists {
1096
            return stories, contextutils.ErrorWithContextf("user not found for story: %s", storyData.Username)
1097
        }
1098

1099
        // Parse section length override if provided
1100
        var sectionLengthOverride *models.SectionLength
1101
        if storyData.SectionLengthOverride != nil {
1102
            switch *storyData.SectionLengthOverride {
1103
            case "short":
1104
                sl := models.SectionLengthShort
1105
                sectionLengthOverride = &sl
1106
            case "medium":
1107
                sl := models.SectionLengthMedium
1108
                sectionLengthOverride = &sl
1109
            case "long":
1110
                sl := models.SectionLengthLong
1111
                sectionLengthOverride = &sl
1112
            }
1113
        }
1114

1115
        // Create story
1116
        story := &models.Story{
1117
            UserID:                uint(user.ID),
1118
            Title:                 storyData.Title,
1119
            Language:              storyData.Language,
1120
            Subject:               storyData.Subject,
1121
            AuthorStyle:           storyData.AuthorStyle,
1122
            TimePeriod:            storyData.TimePeriod,
1123
            Genre:                 storyData.Genre,
1124
            Tone:                  storyData.Tone,
1125
            CharacterNames:        storyData.CharacterNames,
1126
            CustomInstructions:    storyData.CustomInstructions,
1127
            SectionLengthOverride: sectionLengthOverride,
1128
            Status:                models.StoryStatus(storyData.Status),
1129
            CreatedAt:             time.Now(),
1130
            UpdatedAt:             time.Now(),
1131
        }
1132

1133
        // Insert story directly into database
1134
        _, err := db.Exec(`
1135
            INSERT INTO stories (user_id, title, language, subject, author_style, time_period, genre, tone,
1136
                                 character_names, custom_instructions, section_length_override, status,
1137
                                 created_at, updated_at)
1138
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
1139
        `, story.UserID, story.Title, story.Language, story.Subject, story.AuthorStyle, story.TimePeriod,
1140
            story.Genre, story.Tone, story.CharacterNames, story.CustomInstructions, story.SectionLengthOverride,
1141
            string(story.Status), story.CreatedAt, story.UpdatedAt)
1142
        if err != nil {
1143
            return stories, contextutils.WrapErrorf(err, "failed to insert story %d", i)
1144
        }
1145

1146
        // Get the story ID (we need to query it back since we don't have RETURNING)
1147
        var storyID int
1148
        err = db.QueryRow(`
1149
            SELECT id FROM stories WHERE user_id = $1 AND title = $2 ORDER BY created_at DESC LIMIT 1
1150
        `, story.UserID, story.Title).Scan(&storyID)
1151
        if err != nil {
1152
            return stories, contextutils.WrapErrorf(err, "failed to get story ID for story %d", i)
1153
        }
1154

1155
        // Initialize story data for test output
1156
        storyKey := fmt.Sprintf("%s_%s", storyData.Username, storyData.Title)
1157
        storyDataForOutput := TestStoryData{
1158
            ID:       storyID,
1159
            Username: storyData.Username,
1160
            Title:    storyData.Title,
1161
            Status:   storyData.Status,
1162
            Sections: []TestStorySectionData{},
1163
        }
1164

1165
        // Create sections for this story
1166
        for j, sectionData := range storyData.Sections {
1167
            section := &models.StorySection{
1168
                StoryID:        uint(storyID),
1169
                SectionNumber:  sectionData.SectionNumber,
1170
                Content:        sectionData.Content,
1171
                LanguageLevel:  sectionData.LanguageLevel,
1172
                WordCount:      sectionData.WordCount,
1173
                GeneratedBy:    models.GeneratorType(sectionData.GeneratedBy),
1174
                GeneratedAt:    time.Now(),
1175
                GenerationDate: time.Now(),
1176
            }
1177

1178
            // Insert section
1179
            _, err := db.Exec(`
1180
                INSERT INTO story_sections (story_id, section_number, content, language_level, word_count,
1181
                                           generated_by, generated_at, generation_date)
1182
                VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
1183
            `, section.StoryID, section.SectionNumber, section.Content, section.LanguageLevel,
1184
                section.WordCount, string(section.GeneratedBy), section.GeneratedAt, section.GenerationDate)
1185
            if err != nil {
1186
                return stories, contextutils.WrapErrorf(err, "failed to insert section %d for story %d", j, i)
1187
            }
1188

1189
            // Get the section ID
1190
            var sectionID int
1191
            err = db.QueryRow(`
1192
                SELECT id FROM story_sections WHERE story_id = $1 AND section_number = $2
1193
            `, section.StoryID, section.SectionNumber).Scan(&sectionID)
1194
            if err != nil {
1195
                return stories, contextutils.WrapErrorf(err, "failed to get section ID for section %d of story %d", j, i)
1196
            }
1197

1198
            // Add section data to story data for test output
1199
            sectionDataForOutput := TestStorySectionData{
1200
                ID:            sectionID,
1201
                StoryID:       storyID,
1202
                SectionNumber: section.SectionNumber,
1203
                Content:       section.Content,
1204
                LanguageLevel: section.LanguageLevel,
1205
                WordCount:     section.WordCount,
1206
                GeneratedBy:   string(section.GeneratedBy),
1207
            }
1208
            storyDataForOutput.Sections = append(storyDataForOutput.Sections, sectionDataForOutput)
1209

1210
            // Create questions for this section
1211
            for k, questionData := range sectionData.Questions {
1212
                question := &models.StorySectionQuestion{
1213
                    SectionID:          uint(sectionID),
1214
                    QuestionText:       questionData.QuestionText,
1215
                    Options:            questionData.Options,
1216
                    CorrectAnswerIndex: questionData.CorrectAnswerIndex,
1217
                    Explanation:        questionData.Explanation,
1218
                    CreatedAt:          time.Now(),
1219
                }
1220

1221
                // Convert options to JSON for database storage
1222
                optionsJSON, err := json.Marshal(question.Options)
1223
                if err != nil {
1224
                    return stories, contextutils.WrapErrorf(err, "failed to marshal options for question %d for section %d of story %d", k, j, i)
1225
                }
1226

1227
                // Insert question
1228
                _, err = db.Exec(`
1229
                    INSERT INTO story_section_questions (section_id, question_text, options, correct_answer_index, explanation, created_at)
1230
                    VALUES ($1, $2, $3, $4, $5, $6)
1231
                `, question.SectionID, question.QuestionText, optionsJSON, question.CorrectAnswerIndex,
1232
                    question.Explanation, question.CreatedAt)
1233
                if err != nil {
1234
                    return stories, contextutils.WrapErrorf(err, "failed to insert question %d for section %d of story %d", k, j, i)
1235
                }
1236
            }
1237
        }
1238

1239
        // Store story data for test output after all sections are created
1240
        stories[storyKey] = storyDataForOutput
1241

1242
        logger.Info(ctx, "Created test story", map[string]interface{}{
1243
            "username": storyData.Username,
1244
            "title":    storyData.Title,
1245
            "story_id": storyID,
1246
        })
1247
    }
1248

1249
    return stories, nil
1250
}
1251

1252
// loadAndCreateSnippets loads and creates snippets from test data
1253
func loadAndCreateSnippets(ctx context.Context, filePath string, users map[string]*models.User, db *sql.DB, logger *observability.Logger) (map[string]TestSnippetData, error) {
1254
    snippets := make(map[string]TestSnippetData)
1255
    data, err := os.ReadFile(filePath)
1256
    if err != nil {
1257
        // Snippets file is optional, so just return if it doesn't exist
1258
        logger.Info(ctx, "Snippets file not found, skipping", map[string]interface{}{
1259
            "file_path": filePath,
1260
        })
1261
        return snippets, nil
1262
    }
1263

1264
    var testSnippets TestSnippets
1265
    if err := yaml.Unmarshal(data, &testSnippets); err != nil {
1266
        return snippets, contextutils.WrapError(err, "failed to parse snippets data")
1267
    }
1268

1269
    // Create snippets service
1270
    snippetsService := services.NewSnippetsService(db, nil, logger)
1271

1272
    for i, snippetData := range testSnippets.Snippets {
1273
        user, exists := users[snippetData.Username]
1274
        if !exists {
1275
            return snippets, contextutils.ErrorWithContextf("user not found for snippet: %s", snippetData.Username)
1276
        }
1277

1278
        // Create snippet request
1279
        createReq := api.CreateSnippetRequest{
1280
            OriginalText:   snippetData.OriginalText,
1281
            TranslatedText: snippetData.TranslatedText,
1282
            SourceLanguage: snippetData.SourceLanguage,
1283
            TargetLanguage: snippetData.TargetLanguage,
1284
            Context:        snippetData.Context,
1285
        }
1286

1287
        // Create snippet using the service
1288
        snippet, err := snippetsService.CreateSnippet(ctx, int64(user.ID), createReq)
1289
        if err != nil {
1290
            return snippets, contextutils.WrapErrorf(err, "failed to create snippet %d", i)
1291
        }
1292

1293
        // Initialize snippet data for test output
1294
        snippetKey := fmt.Sprintf("%s_%s_%s", snippetData.Username, snippetData.OriginalText, snippetData.SourceLanguage)
1295
        snippets[snippetKey] = TestSnippetData{
1296
            ID:             int(snippet.ID),
1297
            Username:       snippetData.Username,
1298
            OriginalText:   snippet.OriginalText,
1299
            TranslatedText: snippet.TranslatedText,
1300
            SourceLanguage: snippet.SourceLanguage,
1301
            TargetLanguage: snippet.TargetLanguage,
1302
        }
1303

1304
        logger.Info(ctx, "Created test snippet", map[string]interface{}{
1305
            "username":      snippetData.Username,
1306
            "original_text": snippetData.OriginalText,
1307
            "snippet_id":    snippet.ID,
1308
        })
1309
    }
1310

1311
    return snippets, nil
1312
}
1313

1314
// seedTranslationPracticeData seeds minimal sentences and sessions for translation practice endpoints.
1315
// Errors during seeding are logged but do not stop the process.
1316
//
1317
//nolint:unparam // Error return is always nil by design - errors are logged and processing continues
1318
func seedTranslationPracticeData(ctx context.Context, users map[string]*models.User, db *sql.DB, logger *observability.Logger) error {
1319
    type seedUser struct {
1320
        Username string
1321
    }
1322
    targets := []seedUser{
1323
        {Username: "apitestuser"},
1324
        {Username: "apitestuserstory1"},
1325
    }
1326

1327
    insertSentence := `
1328
        INSERT INTO translation_practice_sentences
1329
        (user_id, sentence_text, source_language, target_language, language_level, source_type, topic, created_at, updated_at)
1330
        VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
1331
        RETURNING id
1332
    `
1333
    insertSession := `
1334
        INSERT INTO translation_practice_sessions
1335
        (user_id, sentence_id, original_sentence, user_translation, translation_direction, ai_feedback, ai_score, created_at)
1336
        VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
1337
    `
1338

1339
    for _, t := range targets {
1340
        u := users[t.Username]
1341
        if u == nil {
1342
            continue
1343
        }
1344
        langStr := "italian"
1345
        if u.PreferredLanguage.Valid && u.PreferredLanguage.String != "" {
1346
            langStr = u.PreferredLanguage.String
1347
        }
1348
        levelStr := "B1"
1349
        if u.CurrentLevel.Valid && u.CurrentLevel.String != "" {
1350
            levelStr = u.CurrentLevel.String
1351
        }
1352

1353
        type sentenceSeed struct {
1354
            Text       string
1355
            SourceLang string
1356
            TargetLang string
1357
            Level      string
1358
            SourceType string
1359
            Topic      *string
1360
            Direction  string
1361
        }
1362
        topic := "seeded topic"
1363
        seeds := []sentenceSeed{
1364
            {
1365
                Text:       "Please translate this short seeded sentence for practice.",
1366
                SourceLang: "en",
1367
                TargetLang: langStr,
1368
                Level:      levelStr,
1369
                SourceType: "ai_generated",
1370
                Topic:      &topic,
1371
                Direction:  "en_to_learning",
1372
            },
1373
            {
1374
                Text:       "Questa à una semplice frase di esempio per la pratica.",
1375
                SourceLang: langStr,
1376
                TargetLang: "en",
1377
                Level:      levelStr,
1378
                SourceType: "story_section",
1379
                Topic:      &topic,
1380
                Direction:  "learning_to_en",
1381
            },
1382
        }
1383

1384
        for i, s := range seeds {
1385
            var sentenceID int64
1386
            if err := db.QueryRowContext(
1387
                ctx,
1388
                insertSentence,
1389
                u.ID,
1390
                s.Text,
1391
                s.SourceLang,
1392
                s.TargetLang,
1393
                s.Level,
1394
                s.SourceType,
1395
                s.Topic,
1396
            ).Scan(&sentenceID); err != nil {
1397
                logger.Warn(ctx, "Failed to insert translation practice sentence (likely exists)", map[string]interface{}{"user": t.Username, "err": err.Error(), "index": i})
1398
                continue
1399
            }
1400

1401
            // Create one or two sessions per sentence with varying scores
1402
            type sessSeed struct {
1403
                UserText  string
1404
                Feedback  string
1405
                Score     *float64
1406
                Direction string
1407
            }
1408
            scoreA := 4.6
1409
            scoreB := 3.2
1410
            sessionSeeds := []sessSeed{
1411
                {
1412
                    UserText:  "My attempt at translation.",
1413
                    Feedback:  "Good job overall. Consider refining word choice in the second clause.",
1414
                    Score:     &scoreA,
1415
                    Direction: seeds[i].Direction,
1416
                },
1417
                {
1418
                    UserText:  "Another attempt.",
1419
                    Feedback:  "Acceptable translation. Work on grammar agreement.",
1420
                    Score:     &scoreB,
1421
                    Direction: seeds[i].Direction,
1422
                },
1423
            }
1424
            for _, ss := range sessionSeeds {
1425
                if _, err := db.ExecContext(
1426
                    ctx,
1427
                    insertSession,
1428
                    u.ID,
1429
                    sentenceID,
1430
                    s.Text,
1431
                    ss.UserText,
1432
                    ss.Direction,
1433
                    ss.Feedback,
1434
                    ss.Score,
1435
                ); err != nil {
1436
                    logger.Warn(ctx, "Failed to insert translation practice session", map[string]interface{}{"user": t.Username, "err": err.Error()})
1437
                }
1438
            }
1439
        }
1440
    }
1441

1442
    logger.Info(ctx, "Seeded translation practice data", map[string]interface{}{})
1443
    return nil
1444
}
1445

1446
// outputUserDataForTests outputs the created user data to a JSON file for E2E tests to read
1447
func outputUserDataForTests(users map[string]*models.User, rootDir string, logger *observability.Logger) error {
1448
    // Create a simplified structure for the E2E test
1449
    type TestUserData struct {
1450
        ID       int    `json:"id"`
1451
        Username string `json:"username"`
1452
        Email    string `json:"email"`
1453
    }
1454

1455
    userData := make(map[string]TestUserData)
1456
    for username, user := range users {
1457
        userData[username] = TestUserData{
1458
            ID:       user.ID,
1459
            Username: user.Username,
1460
            Email:    user.Email.String,
1461
        }
1462
    }
1463

1464
    // Write to JSON file in the frontend/tests directory
1465
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-users.json")
1466

1467
    // Ensure the directory exists
1468
    outputDir := filepath.Dir(outputPath)
1469
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
1470
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
1471
    }
1472

1473
    // Marshal to JSON with pretty printing
1474
    jsonData, err := json.MarshalIndent(userData, "", "  ")
1475
    if err != nil {
1476
        return contextutils.WrapErrorf(err, "failed to marshal user data to JSON")
1477
    }
1478

1479
    // Write to file
1480
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
1481
        return contextutils.WrapErrorf(err, "failed to write user data to file: %s", outputPath)
1482
    }
1483

1484
    logger.Info(context.Background(), "Output user data for E2E tests", map[string]interface{}{
1485
        "file_path":  outputPath,
1486
        "user_count": len(userData),
1487
    })
1488

1489
    return nil
1490
}
1491

1492
// outputStoryDataForTests outputs the created story data to a JSON file for E2E tests to read
1493
func outputStoryDataForTests(stories map[string]TestStoryData, rootDir string, logger *observability.Logger) error {
1494
    // Write to JSON file in the frontend/tests directory
1495
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-stories.json")
1496

1497
    // Ensure the directory exists
1498
    outputDir := filepath.Dir(outputPath)
1499
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
1500
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
1501
    }
1502

1503
    // Marshal to JSON with pretty printing
1504
    jsonData, err := json.MarshalIndent(stories, "", "  ")
1505
    if err != nil {
1506
        return contextutils.WrapErrorf(err, "failed to marshal stories data to JSON")
1507
    }
1508

1509
    // Write to file
1510
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
1511
        return contextutils.WrapErrorf(err, "failed to write stories data to file: %s", outputPath)
1512
    }
1513

1514
    logger.Info(context.Background(), "Output stories data for E2E tests", map[string]interface{}{
1515
        "file_path":     outputPath,
1516
        "stories_count": len(stories),
1517
    })
1518

1519
    return nil
1520
}
1521

1522
// outputSnippetDataForTests outputs the created snippet data to a JSON file for E2E tests to read
1523
func outputSnippetDataForTests(snippets map[string]TestSnippetData, rootDir string, logger *observability.Logger) error {
1524
    // Write to JSON file in the frontend/tests directory
1525
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-snippets.json")
1526

1527
    // Ensure the directory exists
1528
    outputDir := filepath.Dir(outputPath)
1529
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
1530
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
1531
    }
1532

1533
    // Marshal to JSON with pretty printing
1534
    jsonData, err := json.MarshalIndent(snippets, "", "  ")
1535
    if err != nil {
1536
        return contextutils.WrapErrorf(err, "failed to marshal snippets data to JSON")
1537
    }
1538

1539
    // Write to file
1540
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
1541
        return contextutils.WrapErrorf(err, "failed to write snippets data to file: %s", outputPath)
1542
    }
1543

1544
    logger.Info(context.Background(), "Output snippets data for E2E tests", map[string]interface{}{
1545
        "file_path":      outputPath,
1546
        "snippets_count": len(snippets),
1547
    })
1548

1549
    return nil
1550
}
1551

1552
// outputRolesDataForTests outputs the created roles data to a JSON file for E2E tests to read
1553
func outputRolesDataForTests(db *sql.DB, rootDir string, logger *observability.Logger) error {
1554
    // Query all roles from the database
1555
    rows, err := db.Query(`
1556
        SELECT id, name, description, created_at, updated_at
1557
        FROM roles
1558
        ORDER BY id
1559
    `)
1560
    if err != nil {
1561
        return contextutils.WrapErrorf(err, "failed to query roles from database")
1562
    }
1563
    defer func() {
1564
        if err := rows.Close(); err != nil {
1565
            logger.Warn(context.Background(), "Warning: failed to close rows", map[string]interface{}{"error": err.Error()})
1566
        }
1567
    }()
1568

1569
    // Create a simplified structure for the E2E test
1570
    type TestRoleData struct {
1571
        ID          int    `json:"id"`
1572
        Name        string `json:"name"`
1573
        Description string `json:"description"`
1574
    }
1575

1576
    roleData := make(map[string]TestRoleData)
1577
    for rows.Next() {
1578
        var role models.Role
1579
        err := rows.Scan(&role.ID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt)
1580
        if err != nil {
1581
            return contextutils.WrapErrorf(err, "failed to scan role data")
1582
        }
1583
        roleData[role.Name] = TestRoleData{
1584
            ID:          role.ID,
1585
            Name:        role.Name,
1586
            Description: role.Description,
1587
        }
1588
    }
1589

1590
    if err := rows.Err(); err != nil {
1591
        return contextutils.WrapErrorf(err, "error iterating over roles")
1592
    }
1593

1594
    // Write to JSON file in the frontend/tests directory
1595
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-roles.json")
1596

1597
    // Ensure the directory exists
1598
    outputDir := filepath.Dir(outputPath)
1599
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
1600
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
1601
    }
1602

1603
    // Marshal to JSON with pretty printing
1604
    jsonData, err := json.MarshalIndent(roleData, "", "  ")
1605
    if err != nil {
1606
        return contextutils.WrapErrorf(err, "failed to marshal roles data to JSON")
1607
    }
1608

1609
    // Write to file
1610
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
1611
        return contextutils.WrapErrorf(err, "failed to write roles data to file: %s", outputPath)
1612
    }
1613

1614
    logger.Info(context.Background(), "Output roles data for E2E tests", map[string]interface{}{
1615
        "file_path":   outputPath,
1616
        "roles_count": len(roleData),
1617
    })
1618

1619
    return nil
1620
}
1621

1622
func loadAndCreateConversations(ctx context.Context, filePath string, users map[string]*models.User, db *sql.DB, logger *observability.Logger) (map[string]TestConversationData, error) {
1623
    conversations := make(map[string]TestConversationData)
1624
    data, err := os.ReadFile(filePath)
1625
    if err != nil {
1626
        // Conversations file is optional, so just return if it doesn't exist
1627
        logger.Info(ctx, "Conversations file not found, skipping", map[string]interface{}{
1628
            "file_path": filePath,
1629
        })
1630
        return conversations, nil
1631
    }
1632

1633
    var testConversations TestConversations
1634
    if err := yaml.Unmarshal(data, &testConversations); err != nil {
1635
        return conversations, contextutils.WrapError(err, "failed to parse conversations data")
1636
    }
1637

1638
    // Create conversation service
1639
    conversationService := services.NewConversationService(db)
1640

1641
    for i, convData := range testConversations.Conversations {
1642
        user, exists := users[convData.Username]
1643
        if !exists {
1644
            return conversations, contextutils.ErrorWithContextf("user not found for conversation: %s", convData.Username)
1645
        }
1646

1647
        // Create conversation
1648
        createReq := &api.CreateConversationRequest{
1649
            Title: convData.Title,
1650
        }
1651

1652
        conversation, err := conversationService.CreateConversation(ctx, uint(user.ID), createReq)
1653
        if err != nil {
1654
            return conversations, contextutils.WrapErrorf(err, "failed to create conversation %d", i)
1655
        }
1656

1657
        // Store conversation data for test output (messages will be added below)
1658
        convKey := fmt.Sprintf("%s_%s", convData.Username, convData.Title)
1659
        conversations[convKey] = TestConversationData{
1660
            ID:       conversation.Id.String(),
1661
            Username: convData.Username,
1662
            Title:    convData.Title,
1663
            Messages: []TestMessageData{},
1664
        }
1665

1666
        // Create messages for this conversation
1667
        for j, msgData := range convData.Messages {
1668
            content := struct {
1669
                Text *string `json:"text,omitempty"`
1670
            }{
1671
                Text: &msgData.Content,
1672
            }
1673

1674
            createMsgReq := &api.CreateMessageRequest{
1675
                Content:    content,
1676
                Role:       api.CreateMessageRequestRole(msgData.Role),
1677
                QuestionId: msgData.QuestionID,
1678
            }
1679

1680
            _, err := conversationService.AddMessage(ctx, conversation.Id.String(), uint(user.ID), createMsgReq)
1681
            if err != nil {
1682
                return conversations, contextutils.WrapErrorf(err, "failed to add message %d for conversation %d", j, i)
1683
            }
1684
        }
1685

1686
        // Now retrieve all messages for this conversation to get their actual data
1687
        messages, err := conversationService.GetConversationMessages(ctx, conversation.Id.String(), uint(user.ID))
1688
        if err != nil {
1689
            return conversations, contextutils.WrapErrorf(err, "failed to get messages for conversation %d", i)
1690
        }
1691

1692
        // Convert messages to our test data format
1693
        var testMessages []TestMessageData
1694
        for _, msg := range messages {
1695
            testMsg := TestMessageData{
1696
                ID:             msg.Id.String(),
1697
                ConversationID: msg.ConversationId.String(),
1698
                Role:           string(msg.Role),
1699
                Bookmarked:     false, // Default value
1700
                CreatedAt:      msg.CreatedAt.Format(time.RFC3339),
1701
                UpdatedAt:      msg.UpdatedAt.Format(time.RFC3339),
1702
            }
1703

1704
            if msg.QuestionId != nil {
1705
                testMsg.QuestionID = msg.QuestionId
1706
            }
1707

1708
            if msg.Content.Text != nil {
1709
                testMsg.Content = *msg.Content.Text
1710
            }
1711

1712
            testMessages = append(testMessages, testMsg)
1713
        }
1714

1715
        // Update the conversation with the actual messages
1716
        conversations[convKey] = TestConversationData{
1717
            ID:       conversation.Id.String(),
1718
            Username: convData.Username,
1719
            Title:    convData.Title,
1720
            Messages: testMessages,
1721
        }
1722

1723
        logger.Info(ctx, "Created test conversation", map[string]interface{}{
1724
            "username":        convData.Username,
1725
            "title":           convData.Title,
1726
            "conversation_id": conversation.Id,
1727
        })
1728
    }
1729

1730
    return conversations, nil
1731
}
1732

1733
// outputConversationDataForTests outputs the created conversation data to a JSON file for E2E tests to read
1734
func outputConversationDataForTests(conversations map[string]TestConversationData, rootDir string, logger *observability.Logger) error {
1735
    // Write to JSON file in the frontend/tests directory
1736
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-conversations.json")
1737

1738
    // Ensure the directory exists
1739
    outputDir := filepath.Dir(outputPath)
1740
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
1741
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
1742
    }
1743

1744
    // Marshal to JSON with pretty printing
1745
    jsonData, err := json.MarshalIndent(conversations, "", "  ")
1746
    if err != nil {
1747
        return contextutils.WrapErrorf(err, "failed to marshal conversations data to JSON")
1748
    }
1749

1750
    // Write to file
1751
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
1752
        return contextutils.WrapErrorf(err, "failed to write conversations data to file: %s", outputPath)
1753
    }
1754

1755
    logger.Info(context.Background(), "Output conversations data for E2E tests", map[string]interface{}{
1756
        "file_path":           outputPath,
1757
        "conversations_count": len(conversations),
1758
    })
1759

1760
    return nil
1761
}
1762

1763
// loadAndCreateFeedback loads and creates feedback reports from test data
1764
func loadAndCreateFeedback(ctx context.Context, filePath string, users map[string]*models.User, db *sql.DB, logger *observability.Logger) (map[string]TestFeedbackData, error) {
1765
    feedback := make(map[string]TestFeedbackData)
1766
    data, err := os.ReadFile(filePath)
1767
    if err != nil {
1768
        // Feedback file is optional, so just return if it doesn't exist
1769
        logger.Info(ctx, "Feedback file not found, skipping", map[string]interface{}{
1770
            "file_path": filePath,
1771
        })
1772
        return feedback, nil
1773
    }
1774

1775
    var testFeedback TestFeedback
1776
    if err := yaml.Unmarshal(data, &testFeedback); err != nil {
1777
        return feedback, contextutils.WrapError(err, "failed to parse feedback data")
1778
    }
1779

1780
    for i, feedbackData := range testFeedback.FeedbackReports {
1781
        user, exists := users[feedbackData.Username]
1782
        if !exists {
1783
            return feedback, contextutils.ErrorWithContextf("user not found for feedback: %s", feedbackData.Username)
1784
        }
1785

1786
        // Default values
1787
        feedbackType := feedbackData.FeedbackType
1788
        if feedbackType == "" {
1789
            feedbackType = "general"
1790
        }
1791
        status := feedbackData.Status
1792
        if status == "" {
1793
            status = "new"
1794
        }
1795

1796
        // Marshal context_data to JSON
1797
        contextJSON, err := json.Marshal(feedbackData.ContextData)
1798
        if err != nil {
1799
            return feedback, contextutils.WrapErrorf(err, "failed to marshal context_data for feedback %d", i)
1800
        }
1801

1802
        // Insert feedback directly into database
1803
        var feedbackID int
1804
        err = db.QueryRow(`
1805
            INSERT INTO feedback_reports (user_id, feedback_text, feedback_type, context_data, status, created_at, updated_at)
1806
            VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
1807
            RETURNING id
1808
        `, user.ID, feedbackData.FeedbackText, feedbackType, contextJSON, status).Scan(&feedbackID)
1809
        if err != nil {
1810
            return feedback, contextutils.WrapErrorf(err, "failed to insert feedback %d", i)
1811
        }
1812

1813
        // Store feedback data for test output
1814
        feedbackKey := fmt.Sprintf("%s_%d", feedbackData.Username, i)
1815
        feedback[feedbackKey] = TestFeedbackData{
1816
            ID:           feedbackID,
1817
            Username:     feedbackData.Username,
1818
            FeedbackText: feedbackData.FeedbackText,
1819
            FeedbackType: feedbackType,
1820
            Status:       status,
1821
            ContextData:  feedbackData.ContextData,
1822
        }
1823

1824
        logger.Info(ctx, "Created test feedback", map[string]interface{}{
1825
            "username":      feedbackData.Username,
1826
            "feedback_id":   feedbackID,
1827
            "status":        status,
1828
            "feedback_type": feedbackType,
1829
        })
1830
    }
1831

1832
    return feedback, nil
1833
}
1834

1835
// outputFeedbackDataForTests outputs the created feedback data to a JSON file for E2E tests to read
1836
func outputFeedbackDataForTests(feedback map[string]TestFeedbackData, rootDir string, logger *observability.Logger) error {
1837
    // Write to JSON file in the frontend/tests directory
1838
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-feedback.json")
1839

1840
    // Ensure the directory exists
1841
    outputDir := filepath.Dir(outputPath)
1842
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
1843
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
1844
    }
1845

1846
    // Marshal to JSON with pretty printing
1847
    jsonData, err := json.MarshalIndent(feedback, "", "  ")
1848
    if err != nil {
1849
        return contextutils.WrapErrorf(err, "failed to marshal feedback data to JSON")
1850
    }
1851

1852
    // Write to file
1853
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
1854
        return contextutils.WrapErrorf(err, "failed to write feedback data to file: %s", outputPath)
1855
    }
1856

1857
    logger.Info(context.Background(), "Output feedback data for E2E tests", map[string]interface{}{
1858
        "file_path":      outputPath,
1859
        "feedback_count": len(feedback),
1860
    })
1861

1862
    return nil
1863
}
1864


			
quizapp cmd worker
0.0%
Statements
0/138
main.go
0.0%
0/138
quizapp cmd worker main.go
0.0%
Statements
0/138
1
// Package main provides the entry point for the Quiz Application worker service.
2
package main
3

4
import (
5
    "context"
6
    "io/fs"
7
    "net/http"
8
    "os"
9
    "os/signal"
10
    "syscall"
11
    "time"
12

13
    "quizapp/internal/config"
14
    "quizapp/internal/database"
15
    "quizapp/internal/handlers"
16
    "quizapp/internal/middleware"
17
    "quizapp/internal/observability"
18
    "quizapp/internal/services"
19
    "quizapp/internal/version"
20
    "quizapp/internal/worker"
21

22
    "github.com/gin-contrib/sessions"
23
    "github.com/gin-contrib/sessions/cookie"
24
    "github.com/gin-gonic/gin"
25
)
26

27
// fatalIfErr logs the error with context and panics with a consistent message
28
func fatalIfErr(ctx context.Context, logger *observability.Logger, msg string, err error, fields map[string]interface{}) {
29
    logger.Error(ctx, msg, err, fields)
30
    panic(msg + ": " + err.Error())
31
}
32

33
func main() {
34
    ctx := context.Background()
35

36
    // Load configuration
37
    cfg, err := config.NewConfig()
38
    if err != nil {
39
        panic("Failed to load configuration: " + err.Error())
40
    }
41

42
    // Setup observability (tracing/metrics/logging)
43
    _, _, logger, err := observability.SetupObservability(&cfg.OpenTelemetry, "quiz-worker")
44
    if err != nil {
45
        panic("Failed to initialize observability: " + err.Error())
46
    }
47

48
    logger.Info(ctx, "Starting quiz worker service", map[string]interface{}{
49
        "port":     cfg.Server.WorkerPort,
50
        "logLevel": cfg.Server.LogLevel,
51
        "debug":    cfg.Server.Debug,
52
    })
53

54
    // Initialize database manager with logger
55
    dbManager := database.NewManager(logger)
56

57
    // Initialize database connection without running migrations (migrations are managed elsewhere)
58
    db, err := dbManager.InitDBWithoutMigrations(cfg.Database)
59
    if err != nil {
60
        fatalIfErr(ctx, logger, "Failed to initialize database", err, map[string]interface{}{"db_url": cfg.Database.URL})
61
    }
62
    defer func() {
63
        if err := db.Close(); err != nil {
64
            logger.Warn(ctx, "Warning: failed to close database", map[string]interface{}{"error": err.Error(), "db_url": cfg.Database.URL})
65
        }
66
    }()
67

68
    // Initialize services
69
    userService := services.NewUserServiceWithLogger(db, cfg, logger)
70
    learningService := services.NewLearningServiceWithLogger(db, cfg, logger)
71
    // Create question service
72
    questionService := services.NewQuestionServiceWithLogger(db, learningService, cfg, logger)
73
    // Create usage stats service
74
    usageStatsService := services.NewUsageStatsService(cfg, db, logger)
75
    aiService := services.NewAIService(cfg, logger, usageStatsService)
76
    workerService := services.NewWorkerServiceWithLogger(db, logger)
77
    generationHintService := services.NewGenerationHintService(db, logger)
78
    emailService := services.CreateEmailServiceWithDB(cfg, logger, db)
79
    // Create daily question service
80
    dailyQuestionService := services.NewDailyQuestionService(db, logger, questionService, learningService)
81

82
    // Create word of the day service
83
    wordOfTheDayService := services.NewWordOfTheDayService(db, logger)
84

85
    // Create story service
86
    storyService := services.NewStoryService(db, cfg, logger)
87

88
    // Create translation cache repository
89
    translationCacheRepo := services.NewTranslationCacheRepository(db, logger)
90

91
    // Initialize worker with the observability logger
92
    workerInstance := worker.NewWorker(userService, questionService, aiService, learningService, workerService, dailyQuestionService, wordOfTheDayService, storyService, emailService, generationHintService, translationCacheRepo, "default", cfg, logger)
93
    go workerInstance.Start(ctx)
94

95
    // Initialize admin handler for worker UI
96
    adminHandler := handlers.NewWorkerAdminHandlerWithLogger(userService, questionService, aiService, cfg, workerInstance, workerService, learningService, dailyQuestionService, logger)
97

98
    // Setup Gin router
99
    gin.SetMode(gin.ReleaseMode)
100
    if cfg.Server.Debug {
101
        gin.SetMode(gin.DebugMode)
102
    }
103
    router := gin.New()
104
    router.Use(gin.Recovery())
105

106
    // Add HTTP request logging middleware using our observability logger
107
    router.Use(func(c *gin.Context) {
108
        start := time.Now()
109

110
        // Process request
111
        c.Next()
112

113
        // Log request details using our observability logger
114
        latency := time.Since(start)
115
        statusCode := c.Writer.Status()
116
        clientIP := c.ClientIP()
117
        method := c.Request.Method
118
        path := c.Request.URL.Path
119

120
        // Create structured log entry
121
        fields := map[string]interface{}{
122
            "http.method":      method,
123
            "http.path":        path,
124
            "http.status_code": statusCode,
125
            "http.latency_ms":  latency.Milliseconds(),
126
            "http.client_ip":   clientIP,
127
            "http.user_agent":  c.Request.UserAgent(),
128
        }
129

130
        // Add error message if present
131
        if len(c.Errors) > 0 {
132
            fields["http.error"] = c.Errors.String()
133
        }
134

135
        // Log using our observability logger (goes to both stdout and OTLP)
136
        // Use appropriate log level based on status code
137
        if statusCode >= 500 {
138
            logger.Error(c.Request.Context(), "HTTP request failed", nil, fields)
139
        } else if statusCode >= 400 {
140
            logger.Warn(c.Request.Context(), "HTTP request warning", fields)
141
        } else {
142
            logger.Info(c.Request.Context(), "HTTP request", fields)
143
        }
144
    })
145

146
    // Add OpenTelemetry middleware for HTTP tracing with automatic error attributes
147
    router.Use(observability.GinMiddlewareWithErrorHandling("quiz-worker"))
148

149
    // Add CORS middleware
150
    router.Use(func(c *gin.Context) {
151
        c.Header("Access-Control-Allow-Origin", "*")
152
        c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
153
        c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
154

155
        if c.Request.Method == "OPTIONS" {
156
            c.AbortWithStatus(204)
157
            return
158
        }
159

160
        c.Next()
161
    })
162

163
    // Setup session middleware
164
    store := cookie.NewStore([]byte(cfg.Server.SessionSecret))
165
    router.Use(sessions.Sessions(config.SessionName, store))
166

167
    // Setup routes
168
    v1 := router.Group("/v1")
169
    {
170
        // Health check route
171
        v1.GET("/health", func(c *gin.Context) {
172
            c.JSON(http.StatusOK, gin.H{"status": "ok"})
173
        })
174

175
        // Version route
176
        v1.GET("/version", func(c *gin.Context) {
177
            c.JSON(http.StatusOK, gin.H{
178
                "service":   "worker",
179
                "version":   version.Version,
180
                "commit":    version.Commit,
181
                "buildTime": version.BuildTime,
182
            })
183
        })
184
    }
185

186
    // Serve static assets (CSS/JS) for worker admin dashboard
187
    staticFS, _ := fs.Sub(handlers.AssetsFS, "templates/assets")
188
    router.StaticFS("/worker", http.FS(staticFS))
189

190
    // Config dump endpoint
191
    router.GET("/configz", adminHandler.GetConfigz)
192

193
    // API routes for worker management
194
    api := router.Group("/v1")
195
    {
196
        // Admin worker endpoints (for frontend)
197
        adminWorker := api.Group("/admin/worker")
198
        adminWorker.Use(middleware.RequireAuth())
199
        {
200
            adminWorker.GET("/details", adminHandler.GetWorkerDetails)
201
            adminWorker.GET("/status", adminHandler.GetWorkerStatus)
202
            adminWorker.GET("/logs", adminHandler.GetActivityLogs)
203
            adminWorker.POST("/pause", adminHandler.PauseWorker)
204
            adminWorker.POST("/resume", adminHandler.ResumeWorker)
205
            adminWorker.POST("/trigger", adminHandler.TriggerWorkerRun)
206
            adminWorker.GET("/ai-concurrency", adminHandler.GetAIConcurrencyStats)
207
        }
208

209
        // Worker user control endpoints (for pausing/resuming user question generation)
210
        workerUsers := api.Group("/admin/worker/users")
211
        workerUsers.Use(middleware.RequireAuth())
212
        {
213
            workerUsers.GET("/", adminHandler.GetWorkerUsers)
214
            workerUsers.POST("/pause", adminHandler.PauseWorkerUser)
215
            workerUsers.POST("/resume", adminHandler.ResumeWorkerUser)
216
        }
217

218
        // System health for worker
219
        system := api.Group("/system")
220
        {
221
            system.GET("/health", adminHandler.GetSystemHealth)
222
        }
223

224
        // Admin analytics endpoints (for frontend)
225
        adminAnalytics := api.Group("/admin/worker/analytics")
226
        adminAnalytics.Use(middleware.RequireAuth())
227
        {
228
            adminAnalytics.GET("/priority-scores", adminHandler.GetPriorityAnalytics)
229
            adminAnalytics.GET("/user-performance", adminHandler.GetUserPerformanceAnalytics)
230
            adminAnalytics.GET("/generation-intelligence", adminHandler.GetGenerationIntelligence)
231
            adminAnalytics.GET("/system-health", adminHandler.GetSystemHealthAnalytics)
232
            adminAnalytics.GET("/comparison", adminHandler.GetUserComparisonAnalytics)
233
            adminAnalytics.GET("/user/:userID", adminHandler.GetUserPriorityAnalytics)
234
        }
235

236
        // Admin daily questions endpoints (for frontend)
237
        adminDaily := api.Group("/admin/worker/daily")
238
        adminDaily.Use(middleware.RequireAuth())
239
        {
240
            adminDaily.GET("/users/:userId/questions/:date", adminHandler.GetUserDailyQuestions)
241
            adminDaily.POST("/users/:userId/questions/:date/regenerate", adminHandler.RegenerateUserDailyQuestions)
242
        }
243

244
        // Admin notification endpoints (for frontend)
245
        adminNotifications := api.Group("/admin/worker/notifications")
246
        adminNotifications.Use(middleware.RequireAuth())
247
        {
248
            adminNotifications.GET("/stats", adminHandler.GetNotificationStats)
249
            adminNotifications.GET("/errors", adminHandler.GetNotificationErrors)
250
            adminNotifications.GET("/sent", adminHandler.GetSentNotifications)
251
            adminNotifications.POST("/test/create-sent", adminHandler.CreateTestSentNotification)
252
            adminNotifications.POST("/force-send", adminHandler.ForceSendNotification)
253
        }
254
    }
255

256
    // Automatic route listing at root path
257
    routeListing := handlers.NewRouteListingHandler("Worker")
258
    routeListing.CollectRoutes(router)
259

260
    // Root path shows all available routes
261
    router.GET("/", func(c *gin.Context) {
262
        // Support JSON output via query parameter
263
        if c.Query("json") == "true" {
264
            routeListing.GetRouteListingJSON(c)
265
        } else {
266
            routeListing.GetRouteListingPage(c)
267
        }
268
    })
269

270
    // Create HTTP server
271
    srv := &http.Server{
272
        Addr:    ":" + cfg.Server.WorkerPort,
273
        Handler: router,
274
    }
275

276
    // Start server in a goroutine
277
    go func() {
278
        logger.Info(ctx, "Worker server starting", map[string]interface{}{"port": cfg.Server.WorkerPort})
279
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
280
            fatalIfErr(ctx, logger, "Failed to start worker server", err, map[string]interface{}{"port": cfg.Server.WorkerPort})
281
        }
282
    }()
283

284
    // Wait for interrupt signal to gracefully shutdown
285
    quit := make(chan os.Signal, 1)
286
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
287
    <-quit
288
    logger.Info(ctx, "Worker server shutting down", map[string]interface{}{"service": "worker"})
289

290
    // Graceful shutdown with timeout
291
    shutdownCtx, shutdownCancel := context.WithTimeout(ctx, config.WorkerShutdownTimeout)
292
    defer shutdownCancel()
293

294
    // Shutdown the worker first
295
    if err := workerInstance.Shutdown(shutdownCtx); err != nil {
296
        logger.Warn(ctx, "Warning: failed to shutdown worker", map[string]interface{}{"error": err.Error(), "service": "worker"})
297
    }
298

299
    // Then shutdown the server
300
    if err := srv.Shutdown(shutdownCtx); err != nil {
301
        fatalIfErr(ctx, logger, "Worker server forced to shutdown", err, map[string]interface{}{"service": "worker"})
302
    }
303

304
    logger.Info(ctx, "Worker server exited", map[string]interface{}{"service": "worker"})
305
}
306


			
quizapp internal
55.2%
Statements
9340/16932
api
0.0%
0/240
config
79.8%
162/203
database
81.5%
181/222
di
95.0%
115/121
handlers
49.2%
2735/5564
middleware
47.8%
358/749
models
42.9%
36/84
observability
56.6%
138/244
services
57.8%
4794/8290
utils
56.8%
147/259
worker
70.5%
674/956
quizapp internal api
0.0%
Statements
0/240
generated.go
0.0%
0/240
quizapp internal api generated.go
0.0%
Statements
0/240
1
// Package api provides primitives to interact with the openapi HTTP API.
2
//
3
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT.
4
package api
5

6
import (
7
    "encoding/json"
8
    "fmt"
9
    "time"
10

11
    "github.com/oapi-codegen/runtime"
12
    openapi_types "github.com/oapi-codegen/runtime/types"
13
)
14

15
const (
16
    ApiKeyQueryScopes = "apiKeyQuery.Scopes"
17
    BearerAuthScopes  = "bearerAuth.Scopes"
18
    CookieAuthScopes  = "cookieAuth.Scopes"
19
    SessionAuthScopes = "sessionAuth.Scopes"
20
)
21

22
// Defines values for APIKeySummaryPermissionLevel.
23
const (
24
    APIKeySummaryPermissionLevelFull     APIKeySummaryPermissionLevel = "full"
25
    APIKeySummaryPermissionLevelReadonly APIKeySummaryPermissionLevel = "readonly"
26
)
27

28
// Defines values for APIKeyTestResponsePermissionLevel.
29
const (
30
    APIKeyTestResponsePermissionLevelFull     APIKeyTestResponsePermissionLevel = "full"
31
    APIKeyTestResponsePermissionLevelReadonly APIKeyTestResponsePermissionLevel = "readonly"
32
)
33

34
// Defines values for ChatMessageRole.
35
const (
36
    ChatMessageRoleAssistant ChatMessageRole = "assistant"
37
    ChatMessageRoleUser      ChatMessageRole = "user"
38
)
39

40
// Defines values for CreateAPIKeyRequestPermissionLevel.
41
const (
42
    CreateAPIKeyRequestPermissionLevelFull     CreateAPIKeyRequestPermissionLevel = "full"
43
    CreateAPIKeyRequestPermissionLevelReadonly CreateAPIKeyRequestPermissionLevel = "readonly"
44
)
45

46
// Defines values for CreateAPIKeyResponsePermissionLevel.
47
const (
48
    CreateAPIKeyResponsePermissionLevelFull     CreateAPIKeyResponsePermissionLevel = "full"
49
    CreateAPIKeyResponsePermissionLevelReadonly CreateAPIKeyResponsePermissionLevel = "readonly"
50
)
51

52
// Defines values for CreateMessageRequestRole.
53
const (
54
    CreateMessageRequestRoleAssistant CreateMessageRequestRole = "assistant"
55
    CreateMessageRequestRoleUser      CreateMessageRequestRole = "user"
56
)
57

58
// Defines values for CreateStoryRequestSectionLengthOverride.
59
const (
60
    CreateStoryRequestSectionLengthOverrideLong   CreateStoryRequestSectionLengthOverride = "long"
61
    CreateStoryRequestSectionLengthOverrideMedium CreateStoryRequestSectionLengthOverride = "medium"
62
    CreateStoryRequestSectionLengthOverrideShort  CreateStoryRequestSectionLengthOverride = "short"
63
)
64

65
// Defines values for ErrorResponseSeverity.
66
const (
67
    ErrorResponseSeverityError ErrorResponseSeverity = "error"
68
    ErrorResponseSeverityFatal ErrorResponseSeverity = "fatal"
69
    ErrorResponseSeverityInfo  ErrorResponseSeverity = "info"
70
    ErrorResponseSeverityWarn  ErrorResponseSeverity = "warn"
71
)
72

73
// Defines values for FeedbackReportFeedbackType.
74
const (
75
    FeedbackReportFeedbackTypeBug            FeedbackReportFeedbackType = "bug"
76
    FeedbackReportFeedbackTypeFeatureRequest FeedbackReportFeedbackType = "feature_request"
77
    FeedbackReportFeedbackTypeGeneral        FeedbackReportFeedbackType = "general"
78
    FeedbackReportFeedbackTypeImprovement    FeedbackReportFeedbackType = "improvement"
79
)
80

81
// Defines values for FeedbackReportStatus.
82
const (
83
    FeedbackReportStatusDismissed  FeedbackReportStatus = "dismissed"
84
    FeedbackReportStatusInProgress FeedbackReportStatus = "in_progress"
85
    FeedbackReportStatusNew        FeedbackReportStatus = "new"
86
    FeedbackReportStatusResolved   FeedbackReportStatus = "resolved"
87
)
88

89
// Defines values for FeedbackSubmissionRequestFeedbackType.
90
const (
91
    FeedbackSubmissionRequestFeedbackTypeBug            FeedbackSubmissionRequestFeedbackType = "bug"
92
    FeedbackSubmissionRequestFeedbackTypeFeatureRequest FeedbackSubmissionRequestFeedbackType = "feature_request"
93
    FeedbackSubmissionRequestFeedbackTypeGeneral        FeedbackSubmissionRequestFeedbackType = "general"
94
    FeedbackSubmissionRequestFeedbackTypeImprovement    FeedbackSubmissionRequestFeedbackType = "improvement"
95
)
96

97
// Defines values for FeedbackUpdateRequestStatus.
98
const (
99
    FeedbackUpdateRequestStatusDismissed  FeedbackUpdateRequestStatus = "dismissed"
100
    FeedbackUpdateRequestStatusInProgress FeedbackUpdateRequestStatus = "in_progress"
101
    FeedbackUpdateRequestStatusNew        FeedbackUpdateRequestStatus = "new"
102
    FeedbackUpdateRequestStatusResolved   FeedbackUpdateRequestStatus = "resolved"
103
)
104

105
// Defines values for HealthErrorResponseStatus.
106
const (
107
    Unhealthy HealthErrorResponseStatus = "unhealthy"
108
)
109

110
// Defines values for HealthStatusResponseStatus.
111
const (
112
    Ok HealthStatusResponseStatus = "ok"
113
)
114

115
// Defines values for NotificationErrorErrorType.
116
const (
117
    NotificationErrorErrorTypeEmailDisabled NotificationErrorErrorType = "email_disabled"
118
    NotificationErrorErrorTypeOther         NotificationErrorErrorType = "other"
119
    NotificationErrorErrorTypeSmtpError     NotificationErrorErrorType = "smtp_error"
120
    NotificationErrorErrorTypeTemplateError NotificationErrorErrorType = "template_error"
121
    NotificationErrorErrorTypeUserNotFound  NotificationErrorErrorType = "user_not_found"
122
)
123

124
// Defines values for NotificationErrorNotificationType.
125
const (
126
    NotificationErrorNotificationTypeDailyReminder NotificationErrorNotificationType = "daily_reminder"
127
    NotificationErrorNotificationTypeTestEmail     NotificationErrorNotificationType = "test_email"
128
)
129

130
// Defines values for QuestionStatus.
131
const (
132
    QuestionStatusActive   QuestionStatus = "active"
133
    QuestionStatusReported QuestionStatus = "reported"
134
)
135

136
// Defines values for QuestionType.
137
const (
138
    FillBlank            QuestionType = "fill_blank"
139
    Qa                   QuestionType = "qa"
140
    ReadingComprehension QuestionType = "reading_comprehension"
141
    Vocabulary           QuestionType = "vocabulary"
142
)
143

144
// Defines values for SentNotificationNotificationType.
145
const (
146
    SentNotificationNotificationTypeDailyReminder SentNotificationNotificationType = "daily_reminder"
147
    SentNotificationNotificationTypeTestEmail     SentNotificationNotificationType = "test_email"
148
)
149

150
// Defines values for SentNotificationStatus.
151
const (
152
    SentNotificationStatusBounced SentNotificationStatus = "bounced"
153
    SentNotificationStatusFailed  SentNotificationStatus = "failed"
154
    SentNotificationStatusSent    SentNotificationStatus = "sent"
155
)
156

157
// Defines values for StorySectionLengthOverride.
158
const (
159
    StorySectionLengthOverrideLong   StorySectionLengthOverride = "long"
160
    StorySectionLengthOverrideMedium StorySectionLengthOverride = "medium"
161
    StorySectionLengthOverrideShort  StorySectionLengthOverride = "short"
162
)
163

164
// Defines values for StoryStatus.
165
const (
166
    StoryStatusActive    StoryStatus = "active"
167
    StoryStatusArchived  StoryStatus = "archived"
168
    StoryStatusCompleted StoryStatus = "completed"
169
)
170

171
// Defines values for StoryWithSectionsSectionLengthOverride.
172
const (
173
    Long   StoryWithSectionsSectionLengthOverride = "long"
174
    Medium StoryWithSectionsSectionLengthOverride = "medium"
175
    Short  StoryWithSectionsSectionLengthOverride = "short"
176
)
177

178
// Defines values for StoryWithSectionsStatus.
179
const (
180
    Active    StoryWithSectionsStatus = "active"
181
    Archived  StoryWithSectionsStatus = "archived"
182
    Completed StoryWithSectionsStatus = "completed"
183
)
184

185
// Defines values for TTSRequestStreamFormat.
186
const (
187
    Audio       TTSRequestStreamFormat = "audio"
188
    AudioStream TTSRequestStreamFormat = "audio_stream"
189
    Sse         TTSRequestStreamFormat = "sse"
190
)
191

192
// Defines values for TTSResponseType.
193
const (
194
    TTSResponseTypeAudio TTSResponseType = "audio"
195
    TTSResponseTypeError TTSResponseType = "error"
196
    TTSResponseTypeUsage TTSResponseType = "usage"
197
)
198

199
// Defines values for TranslationPracticeGenerateRequestDirection.
200
const (
201
    TranslationPracticeGenerateRequestDirectionEnToLearning TranslationPracticeGenerateRequestDirection = "en_to_learning"
202
    TranslationPracticeGenerateRequestDirectionLearningToEn TranslationPracticeGenerateRequestDirection = "learning_to_en"
203
)
204

205
// Defines values for TranslationPracticeSubmitRequestTranslationDirection.
206
const (
207
    TranslationPracticeSubmitRequestTranslationDirectionEnToLearning TranslationPracticeSubmitRequestTranslationDirection = "en_to_learning"
208
    TranslationPracticeSubmitRequestTranslationDirectionLearningToEn TranslationPracticeSubmitRequestTranslationDirection = "learning_to_en"
209
)
210

211
// Defines values for WordOfTheDayDisplaySourceType.
212
const (
213
    WordOfTheDayDisplaySourceTypeSnippet            WordOfTheDayDisplaySourceType = "snippet"
214
    WordOfTheDayDisplaySourceTypeVocabularyQuestion WordOfTheDayDisplaySourceType = "vocabulary_question"
215
)
216

217
// Defines values for WorkerStatusStatus.
218
const (
219
    Busy  WorkerStatusStatus = "busy"
220
    Error WorkerStatusStatus = "error"
221
    Idle  WorkerStatusStatus = "idle"
222
)
223

224
// Defines values for DeleteV1AdminBackendFeedbackParamsStatus.
225
const (
226
    DeleteV1AdminBackendFeedbackParamsStatusDismissed  DeleteV1AdminBackendFeedbackParamsStatus = "dismissed"
227
    DeleteV1AdminBackendFeedbackParamsStatusInProgress DeleteV1AdminBackendFeedbackParamsStatus = "in_progress"
228
    DeleteV1AdminBackendFeedbackParamsStatusNew        DeleteV1AdminBackendFeedbackParamsStatus = "new"
229
    DeleteV1AdminBackendFeedbackParamsStatusResolved   DeleteV1AdminBackendFeedbackParamsStatus = "resolved"
230
)
231

232
// Defines values for GetV1AdminBackendFeedbackParamsStatus.
233
const (
234
    GetV1AdminBackendFeedbackParamsStatusDismissed  GetV1AdminBackendFeedbackParamsStatus = "dismissed"
235
    GetV1AdminBackendFeedbackParamsStatusInProgress GetV1AdminBackendFeedbackParamsStatus = "in_progress"
236
    GetV1AdminBackendFeedbackParamsStatusNew        GetV1AdminBackendFeedbackParamsStatus = "new"
237
    GetV1AdminBackendFeedbackParamsStatusResolved   GetV1AdminBackendFeedbackParamsStatus = "resolved"
238
)
239

240
// Defines values for GetV1AdminBackendUserzPaginatedParamsAiEnabled.
241
const (
242
    GetV1AdminBackendUserzPaginatedParamsAiEnabledFalse GetV1AdminBackendUserzPaginatedParamsAiEnabled = "false"
243
    GetV1AdminBackendUserzPaginatedParamsAiEnabledTrue  GetV1AdminBackendUserzPaginatedParamsAiEnabled = "true"
244
)
245

246
// Defines values for GetV1AdminBackendUserzPaginatedParamsActive.
247
const (
248
    GetV1AdminBackendUserzPaginatedParamsActiveFalse GetV1AdminBackendUserzPaginatedParamsActive = "false"
249
    GetV1AdminBackendUserzPaginatedParamsActiveTrue  GetV1AdminBackendUserzPaginatedParamsActive = "true"
250
)
251

252
// Defines values for GetV1AdminWorkerNotificationsErrorsParamsErrorType.
253
const (
254
    GetV1AdminWorkerNotificationsErrorsParamsErrorTypeEmailDisabled GetV1AdminWorkerNotificationsErrorsParamsErrorType = "email_disabled"
255
    GetV1AdminWorkerNotificationsErrorsParamsErrorTypeOther         GetV1AdminWorkerNotificationsErrorsParamsErrorType = "other"
256
    GetV1AdminWorkerNotificationsErrorsParamsErrorTypeSmtpError     GetV1AdminWorkerNotificationsErrorsParamsErrorType = "smtp_error"
257
    GetV1AdminWorkerNotificationsErrorsParamsErrorTypeTemplateError GetV1AdminWorkerNotificationsErrorsParamsErrorType = "template_error"
258
    GetV1AdminWorkerNotificationsErrorsParamsErrorTypeUserNotFound  GetV1AdminWorkerNotificationsErrorsParamsErrorType = "user_not_found"
259
)
260

261
// Defines values for GetV1AdminWorkerNotificationsErrorsParamsNotificationType.
262
const (
263
    GetV1AdminWorkerNotificationsErrorsParamsNotificationTypeDailyReminder GetV1AdminWorkerNotificationsErrorsParamsNotificationType = "daily_reminder"
264
    GetV1AdminWorkerNotificationsErrorsParamsNotificationTypeTestEmail     GetV1AdminWorkerNotificationsErrorsParamsNotificationType = "test_email"
265
)
266

267
// Defines values for GetV1AdminWorkerNotificationsErrorsParamsResolved.
268
const (
269
    False GetV1AdminWorkerNotificationsErrorsParamsResolved = "false"
270
    True  GetV1AdminWorkerNotificationsErrorsParamsResolved = "true"
271
)
272

273
// Defines values for GetV1AdminWorkerNotificationsSentParamsNotificationType.
274
const (
275
    GetV1AdminWorkerNotificationsSentParamsNotificationTypeDailyReminder GetV1AdminWorkerNotificationsSentParamsNotificationType = "daily_reminder"
276
    GetV1AdminWorkerNotificationsSentParamsNotificationTypeTestEmail     GetV1AdminWorkerNotificationsSentParamsNotificationType = "test_email"
277
)
278

279
// Defines values for GetV1AdminWorkerNotificationsSentParamsStatus.
280
const (
281
    GetV1AdminWorkerNotificationsSentParamsStatusBounced GetV1AdminWorkerNotificationsSentParamsStatus = "bounced"
282
    GetV1AdminWorkerNotificationsSentParamsStatusFailed  GetV1AdminWorkerNotificationsSentParamsStatus = "failed"
283
    GetV1AdminWorkerNotificationsSentParamsStatusSent    GetV1AdminWorkerNotificationsSentParamsStatus = "sent"
284
)
285

286
// Defines values for GetV1SnippetsParamsLevel.
287
const (
288
    A1 GetV1SnippetsParamsLevel = "A1"
289
    A2 GetV1SnippetsParamsLevel = "A2"
290
    B1 GetV1SnippetsParamsLevel = "B1"
291
    B2 GetV1SnippetsParamsLevel = "B2"
292
    C1 GetV1SnippetsParamsLevel = "C1"
293
    C2 GetV1SnippetsParamsLevel = "C2"
294
)
295

296
// Defines values for GetV1TranslationPracticeSentenceParamsDirection.
297
const (
298
    EnToLearning GetV1TranslationPracticeSentenceParamsDirection = "en_to_learning"
299
    LearningToEn GetV1TranslationPracticeSentenceParamsDirection = "learning_to_en"
300
)
301

302
// AIConcurrencyStats defines model for AIConcurrencyStats.
303
type AIConcurrencyStats struct {
304
    ActiveRequests  *int            `json:"active_requests,omitempty"`
305
    MaxConcurrent   *int            `json:"max_concurrent,omitempty"`
306
    MaxPerUser      *int            `json:"max_per_user,omitempty"`
307
    QueuedRequests  *int            `json:"queued_requests,omitempty"`
308
    TotalRequests   *int            `json:"total_requests,omitempty"`
309
    UserActiveCount *map[string]int `json:"user_active_count,omitempty"`
310
}
311

312
// AIFixResponse defines model for AIFixResponse.
313
type AIFixResponse struct {
314
    Original   Question `json:"original"`
315
    Suggestion struct {
316
        // AdditionalContext Additional context provided by the admin when requesting the fix
317
        AdditionalContext *string `json:"additional_context,omitempty"`
318

319
        // ChangeReason Explanation of why the AI suggested these changes
320
        ChangeReason *string `json:"change_reason,omitempty"`
321

322
        // ConfidenceLevel Confidence level when question was marked as known (1-5)
323
        ConfidenceLevel *int `json:"confidence_level,omitempty"`
324

325
        // Content All question types now use multiple choice format with 4 options. Either 'question' or 'sentence' must be present depending on question type.
326
        Content *QuestionContent `json:"content,omitempty"`
327

328
        // CorrectAnswer Index of the correct answer in the options array (0-based)
329
        CorrectAnswer *int `json:"correct_answer,omitempty"`
330

331
        // CorrectCount Number of times this question was answered correctly
332
        CorrectCount *int    `json:"correct_count,omitempty"`
333
        CreatedAt    *string `json:"created_at,omitempty"`
334

335
        // DifficultyModifier Difficulty modifier for the question (e.g., basic, intermediate)
336
        DifficultyModifier *string  `json:"difficulty_modifier,omitempty"`
337
        DifficultyScore    *float32 `json:"difficulty_score,omitempty"`
338
        Explanation        *string  `json:"explanation,omitempty"`
339

340
        // GrammarFocus Grammar focus area for the question (e.g., present_perfect, conditionals)
341
        GrammarFocus *string `json:"grammar_focus,omitempty"`
342
        Id           *int64  `json:"id,omitempty"`
343

344
        // IncorrectCount Number of times this question was answered incorrectly
345
        IncorrectCount *int `json:"incorrect_count,omitempty"`
346

347
        // Language Learning language (dynamic). Allowed values come from config.yaml language_levels keys.
348
        Language *Language `json:"language,omitempty"`
349

350
        // Level Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
351
        Level *Level `json:"level,omitempty"`
352

353
        // Reporters Comma-separated list of usernames who reported this question
354
        Reporters *string `json:"reporters,omitempty"`
355

356
        // Scenario Scenario context for the question (e.g., at_the_airport, in_a_restaurant)
357
        Scenario *string         `json:"scenario,omitempty"`
358
        Status   *QuestionStatus `json:"status,omitempty"`
359

360
        // StyleModifier Style modifier for the question (e.g., conversational, formal)
361
        StyleModifier *string `json:"style_modifier,omitempty"`
362

363
        // TimeContext Time context for the question (e.g., morning_routine, workday)
364
        TimeContext *string `json:"time_context,omitempty"`
365

366
        // TopicCategory General topic category for question context (e.g., daily_life, travel, work)
367
        TopicCategory *string `json:"topic_category,omitempty"`
368

369
        // TotalResponses Total number of responses to this question (used for 'Shown' in the UI)
370
        TotalResponses *int          `json:"total_responses,omitempty"`
371
        Type           *QuestionType `json:"type,omitempty"`
372

373
        // UserCount Number of users assigned to this question
374
        UserCount *int `json:"user_count,omitempty"`
375

376
        // VocabularyDomain Vocabulary domain for the question (e.g., food_and_dining, transportation)
377
        VocabularyDomain *string `json:"vocabulary_domain,omitempty"`
378
    } `json:"suggestion"`
379
}
380

381
// AIProviders defines model for AIProviders.
382
type AIProviders struct {
383
    Levels    *[]string `json:"levels,omitempty"`
384
    Providers *[]struct {
385
        Code   *string `json:"code,omitempty"`
386
        Models *[]struct {
387
            Code *string `json:"code,omitempty"`
388
            Name *string `json:"name,omitempty"`
389
        } `json:"models,omitempty"`
390
        Name *string `json:"name,omitempty"`
391
        Url  *string `json:"url,omitempty"`
392

393
        // UsageSupported Whether the provider supports usage tracking in streaming responses
394
        UsageSupported *bool `json:"usage_supported,omitempty"`
395
    } `json:"providers,omitempty"`
396
}
397

398
// APIKeyAvailabilityResponse defines model for APIKeyAvailabilityResponse.
399
type APIKeyAvailabilityResponse struct {
400
    // HasApiKey Whether the user has a saved API key for this provider
401
    HasApiKey bool `json:"has_api_key"`
402
}
403

404
// APIKeySummary defines model for APIKeySummary.
405
type APIKeySummary struct {
406
    // CreatedAt Creation timestamp
407
    CreatedAt *time.Time `json:"created_at,omitempty"`
408

409
    // Id Unique ID
410
    Id *int `json:"id,omitempty"`
411

412
    // KeyName Name of the key
413
    KeyName *string `json:"key_name,omitempty"`
414

415
    // KeyPrefix First characters for identification
416
    KeyPrefix *string `json:"key_prefix,omitempty"`
417

418
    // LastUsedAt Last time this key was used
419
    LastUsedAt *time.Time `json:"last_used_at"`
420

421
    // PermissionLevel Permission level
422
    PermissionLevel *APIKeySummaryPermissionLevel `json:"permission_level,omitempty"`
423

424
    // UpdatedAt Last update timestamp
425
    UpdatedAt *time.Time `json:"updated_at,omitempty"`
426
}
427

428
// APIKeySummaryPermissionLevel Permission level
429
type APIKeySummaryPermissionLevel string
430

431
// APIKeyTestResponse defines model for APIKeyTestResponse.
432
type APIKeyTestResponse struct {
433
    ApiKeyId        *int                               `json:"api_key_id,omitempty"`
434
    Method          *string                            `json:"method,omitempty"`
435
    Ok              *bool                              `json:"ok,omitempty"`
436
    PermissionLevel *APIKeyTestResponsePermissionLevel `json:"permission_level,omitempty"`
437
    UserId          *int                               `json:"user_id,omitempty"`
438
    Username        *string                            `json:"username,omitempty"`
439
}
440

441
// APIKeyTestResponsePermissionLevel defines model for APIKeyTestResponse.PermissionLevel.
442
type APIKeyTestResponsePermissionLevel string
443

444
// APIKeysListResponse defines model for APIKeysListResponse.
445
type APIKeysListResponse struct {
446
    ApiKeys *[]APIKeySummary `json:"api_keys,omitempty"`
447

448
    // Count Total number of keys
449
    Count *int `json:"count,omitempty"`
450
}
451

452
// AdminDailyQuestionsResponse defines model for AdminDailyQuestionsResponse.
453
type AdminDailyQuestionsResponse struct {
454
    Questions []DailyQuestionWithDetails `json:"questions"`
455
}
456

457
// AdminQuestionsResponse defines model for AdminQuestionsResponse.
458
type AdminQuestionsResponse struct {
459
    Pagination PaginationInfo `json:"pagination"`
460
    Questions  []Question     `json:"questions"`
461
    Stats      QuestionStats  `json:"stats"`
462
}
463

464
// AdminReportedQuestionsResponse defines model for AdminReportedQuestionsResponse.
465
type AdminReportedQuestionsResponse struct {
466
    Pagination PaginationInfo         `json:"pagination"`
467
    Questions  []Question             `json:"questions"`
468
    Stats      ReportedQuestionsStats `json:"stats"`
469
}
470

471
// AdminRolesResponse defines model for AdminRolesResponse.
472
type AdminRolesResponse struct {
473
    Roles []Role `json:"roles"`
474
}
475

476
// AdminStoriesResponse defines model for AdminStoriesResponse.
477
type AdminStoriesResponse struct {
478
    Pagination PaginationInfo `json:"pagination"`
479
    Stories    []Story        `json:"stories"`
480
}
481

482
// AdminUsersPaginatedResponse defines model for AdminUsersPaginatedResponse.
483
type AdminUsersPaginatedResponse struct {
484
    Pagination PaginationInfo  `json:"pagination"`
485
    Users      []DashboardUser `json:"users"`
486
}
487

488
// AdminUsersResponse defines model for AdminUsersResponse.
489
type AdminUsersResponse struct {
490
    Users []UserProfile `json:"users"`
491
}
492

493
// AggregatedVersion defines model for AggregatedVersion.
494
type AggregatedVersion struct {
495
    Backend ServiceVersion           `json:"backend"`
496
    Worker  AggregatedVersion_Worker `json:"worker"`
497
}
498

499
// AggregatedVersionWorker1 defines model for .
500
type AggregatedVersionWorker1 struct {
501
    // Error Error message when worker is unavailable
502
    Error string `json:"error"`
503
}
504

505
// AggregatedVersion_Worker defines model for AggregatedVersion.Worker.
506
type AggregatedVersion_Worker struct {
507
    union json.RawMessage
508
}
509

510
// AnswerRequest defines model for AnswerRequest.
511
type AnswerRequest struct {
512
    // QuestionId ID of the question being answered
513
    QuestionId int64 `json:"question_id"`
514

515
    // ResponseTimeMs Response time in milliseconds (0-5 minutes)
516
    ResponseTimeMs *int32 `json:"response_time_ms,omitempty"`
517

518
    // UserAnswerIndex Index of the user's selected answer in the original options array (0-based)
519
    UserAnswerIndex int `json:"user_answer_index"`
520
}
521

522
// AnswerResponse defines model for AnswerResponse.
523
type AnswerResponse struct {
524
    // CorrectAnswerIndex Index of the correct answer in the options array (0-based)
525
    CorrectAnswerIndex *int    `json:"correct_answer_index,omitempty"`
526
    Explanation        *string `json:"explanation,omitempty"`
527
    IsCorrect          *bool   `json:"is_correct,omitempty"`
528
    NextDifficulty     *string `json:"next_difficulty,omitempty"`
529

530
    // UserAnswer The answer selected by the user
531
    UserAnswer *string `json:"user_answer,omitempty"`
532

533
    // UserAnswerIndex Index of the user's selected answer in the original options array (0-based)
534
    UserAnswerIndex *int `json:"user_answer_index,omitempty"`
535
}
536

537
// AuthStatusResponse defines model for AuthStatusResponse.
538
type AuthStatusResponse struct {
539
    // Authenticated Whether the user is currently authenticated
540
    Authenticated bool `json:"authenticated"`
541
    User          User `json:"user"`
542
}
543

544
// BookmarkStatusResponse defines model for BookmarkStatusResponse.
545
type BookmarkStatusResponse struct {
546
    // Bookmarked The new bookmark status of the message
547
    Bookmarked bool `json:"bookmarked"`
548
}
549

550
// BulkDeleteResponse defines model for BulkDeleteResponse.
551
type BulkDeleteResponse struct {
552
    // DeletedCount Number of records deleted
553
    DeletedCount int `json:"deleted_count"`
554
}
555

556
// ChatBookmarksResponse defines model for ChatBookmarksResponse.
557
type ChatBookmarksResponse struct {
558
    // Limit Number of messages returned
559
    Limit    int           `json:"limit"`
560
    Messages []ChatMessage `json:"messages"`
561

562
    // Offset Number of messages skipped
563
    Offset int `json:"offset"`
564

565
    // Query The search query that was used (if any)
566
    Query *string `json:"query,omitempty"`
567

568
    // Total Total number of bookmarked messages
569
    Total int `json:"total"`
570
}
571

572
// ChatMessage defines model for ChatMessage.
573
type ChatMessage struct {
574
    // Bookmarked Whether this message is bookmarked
575
    Bookmarked *bool `json:"bookmarked,omitempty"`
576

577
    // Content Message content
578
    Content struct {
579
        // Text The actual message text
580
        Text *string `json:"text,omitempty"`
581
    } `json:"content"`
582

583
    // ConversationId ID of the conversation this message belongs to
584
    ConversationId openapi_types.UUID `json:"conversation_id"`
585

586
    // ConversationTitle Title of the conversation (optional, included in search results)
587
    ConversationTitle *string `json:"conversation_title,omitempty"`
588

589
    // CreatedAt When the message was created
590
    CreatedAt time.Time `json:"created_at"`
591

592
    // Id Message UUID
593
    Id openapi_types.UUID `json:"id"`
594

595
    // QuestionId Optional question ID if this message relates to a specific question
596
    QuestionId *int `json:"question_id,omitempty"`
597

598
    // Role Role of the message sender
599
    Role ChatMessageRole `json:"role"`
600

601
    // UpdatedAt When the message was last updated
602
    UpdatedAt time.Time `json:"updated_at"`
603
}
604

605
// ChatMessageRole Role of the message sender
606
type ChatMessageRole string
607

608
// Conversation defines model for Conversation.
609
type Conversation struct {
610
    // CreatedAt When the conversation was created
611
    CreatedAt time.Time `json:"created_at"`
612

613
    // Id Conversation UUID
614
    Id openapi_types.UUID `json:"id"`
615

616
    // MessageCount Total number of messages in this conversation
617
    MessageCount *int `json:"message_count,omitempty"`
618

619
    // Messages Array of messages in this conversation (optional, only included when requested)
620
    Messages *[]ChatMessage `json:"messages,omitempty"`
621

622
    // Title Conversation title
623
    Title string `json:"title"`
624

625
    // UpdatedAt When the conversation was last updated
626
    UpdatedAt time.Time `json:"updated_at"`
627

628
    // UserId ID of the user who owns this conversation
629
    UserId int `json:"user_id"`
630
}
631

632
// ConversationSearchResponse defines model for ConversationSearchResponse.
633
type ConversationSearchResponse struct {
634
    Conversations []Conversation `json:"conversations"`
635

636
    // Limit Number of conversations returned
637
    Limit int `json:"limit"`
638

639
    // Offset Number of conversations skipped
640
    Offset int `json:"offset"`
641

642
    // Query The search query that was used
643
    Query *string `json:"query,omitempty"`
644

645
    // Total Total number of conversations
646
    Total int `json:"total"`
647
}
648

649
// ConversationsListResponse defines model for ConversationsListResponse.
650
type ConversationsListResponse struct {
651
    Conversations []Conversation `json:"conversations"`
652

653
    // Limit Number of conversations returned
654
    Limit int `json:"limit"`
655

656
    // Offset Number of conversations skipped
657
    Offset int `json:"offset"`
658

659
    // Total Total number of conversations
660
    Total int `json:"total"`
661
}
662

663
// CreateAPIKeyRequest defines model for CreateAPIKeyRequest.
664
type CreateAPIKeyRequest struct {
665
    // KeyName A descriptive name for the API key
666
    KeyName string `json:"key_name"`
667

668
    // PermissionLevel Permission level: 'readonly' for GET requests only, 'full' for all operations
669
    PermissionLevel CreateAPIKeyRequestPermissionLevel `json:"permission_level"`
670
}
671

672
// CreateAPIKeyRequestPermissionLevel Permission level: 'readonly' for GET requests only, 'full' for all operations
673
type CreateAPIKeyRequestPermissionLevel string
674

675
// CreateAPIKeyResponse defines model for CreateAPIKeyResponse.
676
type CreateAPIKeyResponse struct {
677
    // CreatedAt Creation timestamp
678
    CreatedAt *time.Time `json:"created_at,omitempty"`
679

680
    // Id Unique ID of the API key
681
    Id *int `json:"id,omitempty"`
682

683
    // Key Full API key - only shown once!
684
    Key *string `json:"key,omitempty"`
685

686
    // KeyName Name of the API key
687
    KeyName *string `json:"key_name,omitempty"`
688

689
    // KeyPrefix First characters of key for identification
690
    KeyPrefix *string `json:"key_prefix,omitempty"`
691

692
    // Message Warning message
693
    Message *string `json:"message,omitempty"`
694

695
    // PermissionLevel Permission level
696
    PermissionLevel *CreateAPIKeyResponsePermissionLevel `json:"permission_level,omitempty"`
697
}
698

699
// CreateAPIKeyResponsePermissionLevel Permission level
700
type CreateAPIKeyResponsePermissionLevel string
701

702
// CreateConversationRequest defines model for CreateConversationRequest.
703
type CreateConversationRequest struct {
704
    // Title Title for the conversation
705
    Title string `json:"title"`
706
}
707

708
// CreateLinearIssueResponse defines model for CreateLinearIssueResponse.
709
type CreateLinearIssueResponse struct {
710
    // IssueId The Linear issue ID
711
    IssueId string `json:"issue_id"`
712

713
    // IssueUrl URL to the created Linear issue
714
    IssueUrl string `json:"issue_url"`
715

716
    // Title The title of the created Linear issue
717
    Title string `json:"title"`
718
}
719

720
// CreateMessageRequest defines model for CreateMessageRequest.
721
type CreateMessageRequest struct {
722
    // Content Message content
723
    Content struct {
724
        // Text The actual message text
725
        Text *string `json:"text,omitempty"`
726
    } `json:"content"`
727

728
    // QuestionId Optional question ID if this message relates to a specific question
729
    QuestionId *int `json:"question_id,omitempty"`
730

731
    // Role Role of the message sender
732
    Role CreateMessageRequestRole `json:"role"`
733
}
734

735
// CreateMessageRequestRole Role of the message sender
736
type CreateMessageRequestRole string
737

738
// CreateSnippetRequest defines model for CreateSnippetRequest.
739
type CreateSnippetRequest struct {
740
    // Context Optional user-provided context or notes about this snippet
741
    Context *string `json:"context"`
742

743
    // OriginalText The original text/word to save
744
    OriginalText string `json:"original_text"`
745

746
    // QuestionId Optional ID of the question where this text was encountered. If provided, the snippet will inherit the question's difficulty level (A1, A2, B1, B2, C1, C2)
747
    QuestionId *int64 `json:"question_id"`
748

749
    // SectionId Optional ID of the story section where this text was encountered
750
    SectionId *int64 `json:"section_id"`
751

752
    // SourceLanguage ISO language code of the source text
753
    SourceLanguage string `json:"source_language"`
754

755
    // StoryId Optional ID of the story where this text was encountered
756
    StoryId *int64 `json:"story_id"`
757

758
    // TargetLanguage ISO language code of the target translation
759
    TargetLanguage string `json:"target_language"`
760

761
    // TranslatedText The translated text
762
    TranslatedText string `json:"translated_text"`
763
}
764

765
// CreateStoryRequest defines model for CreateStoryRequest.
766
type CreateStoryRequest struct {
767
    AuthorStyle           *string                                  `json:"author_style"`
768
    CharacterNames        *string                                  `json:"character_names"`
769
    CustomInstructions    *string                                  `json:"custom_instructions"`
770
    Genre                 *string                                  `json:"genre"`
771
    SectionLengthOverride *CreateStoryRequestSectionLengthOverride `json:"section_length_override,omitempty"`
772
    Subject               *string                                  `json:"subject"`
773
    TimePeriod            *string                                  `json:"time_period"`
774
    Title                 string                                   `json:"title"`
775
    Tone                  *string                                  `json:"tone"`
776
}
777

778
// CreateStoryRequestSectionLengthOverride defines model for CreateStoryRequest.SectionLengthOverride.
779
type CreateStoryRequestSectionLengthOverride string
780

781
// DailyAnswerResponse defines model for DailyAnswerResponse.
782
type DailyAnswerResponse struct {
783
    // CorrectAnswerIndex Index of the correct answer in the options array (0-based)
784
    CorrectAnswerIndex *int    `json:"correct_answer_index,omitempty"`
785
    Explanation        *string `json:"explanation,omitempty"`
786

787
    // IsCompleted Whether the question is now completed
788
    IsCompleted    *bool   `json:"is_completed,omitempty"`
789
    IsCorrect      *bool   `json:"is_correct,omitempty"`
790
    NextDifficulty *string `json:"next_difficulty,omitempty"`
791

792
    // UserAnswer The answer selected by the user
793
    UserAnswer *string `json:"user_answer,omitempty"`
794

795
    // UserAnswerIndex Index of the user's selected answer in the original options array (0-based)
796
    UserAnswerIndex *int `json:"user_answer_index,omitempty"`
797
}
798

799
// DailyDatesResponse defines model for DailyDatesResponse.
800
type DailyDatesResponse struct {
801
    Dates []openapi_types.Date `json:"dates"`
802
}
803

804
// DailyProgress defines model for DailyProgress.
805
type DailyProgress struct {
806
    // Completed Number of completed questions
807
    Completed int `json:"completed"`
808

809
    // Date Date for the progress report (YYYY-MM-DD)
810
    Date openapi_types.Date `json:"date"`
811

812
    // Total Total number of questions assigned for the date
813
    Total int `json:"total"`
814
}
815

816
// DailyQuestionHistory defines model for DailyQuestionHistory.
817
type DailyQuestionHistory struct {
818
    // AssignmentDate RFC3339 timestamp of when the question was assigned in the user's timezone (includes offset)
819
    AssignmentDate string `json:"assignment_date"`
820

821
    // IsCompleted Whether the question was completed on this date
822
    IsCompleted bool `json:"is_completed"`
823

824
    // IsCorrect Whether the user's answer was correct (null if not attempted)
825
    IsCorrect *bool `json:"is_correct"`
826

827
    // SubmittedAt When the user submitted their answer
828
    SubmittedAt *string `json:"submitted_at"`
829
}
830

831
// DailyQuestionHistoryResponse defines model for DailyQuestionHistoryResponse.
832
type DailyQuestionHistoryResponse struct {
833
    History []DailyQuestionHistory `json:"history"`
834
}
835

836
// DailyQuestionWithDetails defines model for DailyQuestionWithDetails.
837
type DailyQuestionWithDetails struct {
838
    // AssignmentDate Date-only assignment (YYYY-MM-DD) representing the logical calendar day the question was assigned (no timezone offset)
839
    AssignmentDate openapi_types.Date `json:"assignment_date"`
840

841
    // CompletedAt When the question was completed (if completed)
842
    CompletedAt *string `json:"completed_at"`
843

844
    // CreatedAt When the assignment was created
845
    CreatedAt string `json:"created_at"`
846

847
    // Id Daily question assignment ID
848
    Id int64 `json:"id"`
849

850
    // IsCompleted Whether the question has been completed
851
    IsCompleted bool     `json:"is_completed"`
852
    Question    Question `json:"question"`
853

854
    // QuestionId Question ID
855
    QuestionId int64 `json:"question_id"`
856

857
    // SubmittedAt When the user submitted their answer
858
    SubmittedAt *string `json:"submitted_at"`
859

860
    // UserAnswerIndex The index of the answer option the user selected (0-based)
861
    UserAnswerIndex *int `json:"user_answer_index"`
862

863
    // UserCorrectCount Number of times this user answered this question correctly
864
    UserCorrectCount *int64 `json:"user_correct_count,omitempty"`
865

866
    // UserId User ID
867
    UserId int64 `json:"user_id"`
868

869
    // UserIncorrectCount Number of times this user answered this question incorrectly
870
    UserIncorrectCount *int64 `json:"user_incorrect_count,omitempty"`
871

872
    // UserShownCount Number of times this question was shown to this user in Daily view
873
    UserShownCount *int64 `json:"user_shown_count,omitempty"`
874

875
    // UserTotalResponses Number of times this user answered this question
876
    UserTotalResponses *int64 `json:"user_total_responses,omitempty"`
877
}
878

879
// DailyQuestionsResponse defines model for DailyQuestionsResponse.
880
type DailyQuestionsResponse struct {
881
    Date      openapi_types.Date         `json:"date"`
882
    Questions []DailyQuestionWithDetails `json:"questions"`
883
}
884

885
// DashboardResponse defines model for DashboardResponse.
886
type DashboardResponse struct {
887
    AiConcurrencyStats *AIConcurrencyStats `json:"ai_concurrency_stats,omitempty"`
888
    QuestionStats      *QuestionStats      `json:"question_stats,omitempty"`
889
    Users              *[]DashboardUser    `json:"users,omitempty"`
890
    WorkerBaseUrl      *string             `json:"worker_base_url,omitempty"`
891
    WorkerHealth       *WorkerHealth       `json:"worker_health,omitempty"`
892
    WorkerPort         *string             `json:"worker_port,omitempty"`
893
}
894

895
// DashboardUser defines model for DashboardUser.
896
type DashboardUser struct {
897
    Progress      *UserProgress      `json:"progress,omitempty"`
898
    QuestionStats *UserQuestionStats `json:"question_stats,omitempty"`
899
    User          *UserProfile       `json:"user,omitempty"`
900
}
901

902
// DeleteAPIKeyResponse defines model for DeleteAPIKeyResponse.
903
type DeleteAPIKeyResponse struct {
904
    Message *string `json:"message,omitempty"`
905
    Success *bool   `json:"success,omitempty"`
906
}
907

908
// EmptyRequest Empty request body for endpoints that don't require request data
909
type EmptyRequest = map[string]interface{}
910

911
// ErrorResponse defines model for ErrorResponse.
912
type ErrorResponse struct {
913
    // Cause Underlying error cause (included for error and fatal severity levels)
914
    Cause *string `json:"cause,omitempty"`
915

916
    // Code Error code identifying the type of error
917
    Code *string `json:"code,omitempty"`
918

919
    // Details Additional error details
920
    Details *string `json:"details,omitempty"`
921

922
    // Error Error message (for backward compatibility)
923
    Error *string `json:"error,omitempty"`
924

925
    // Message Human-readable error message
926
    Message *string `json:"message,omitempty"`
927

928
    // Retryable Whether the operation can be retried
929
    Retryable *bool `json:"retryable,omitempty"`
930

931
    // Severity Severity level of the error
932
    Severity *ErrorResponseSeverity `json:"severity,omitempty"`
933
}
934

935
// ErrorResponseSeverity Severity level of the error
936
type ErrorResponseSeverity string
937

938
// FeedbackListResponse defines model for FeedbackListResponse.
939
type FeedbackListResponse struct {
940
    // Items List of feedback reports
941
    Items []FeedbackReport `json:"items"`
942

943
    // Page Current page number
944
    Page int `json:"page"`
945

946
    // PageSize Number of items per page
947
    PageSize int `json:"page_size"`
948

949
    // Total Total number of feedback reports matching filters
950
    Total int `json:"total"`
951
}
952

953
// FeedbackReport defines model for FeedbackReport.
954
type FeedbackReport struct {
955
    // AdminNotes Notes from admin
956
    AdminNotes *string `json:"admin_notes"`
957

958
    // AssignedToUserId User ID assigned to handle this feedback
959
    AssignedToUserId *int64 `json:"assigned_to_user_id"`
960

961
    // ContextData Context metadata as JSON object
962
    ContextData *map[string]interface{} `json:"context_data,omitempty"`
963

964
    // CreatedAt When the feedback was created
965
    CreatedAt time.Time `json:"created_at"`
966

967
    // FeedbackText Feedback or issue description
968
    FeedbackText string `json:"feedback_text"`
969

970
    // FeedbackType Type of feedback
971
    FeedbackType FeedbackReportFeedbackType `json:"feedback_type"`
972

973
    // Id Feedback report ID
974
    Id int64 `json:"id"`
975

976
    // ResolvedAt When the feedback was resolved
977
    ResolvedAt *time.Time `json:"resolved_at"`
978

979
    // ResolvedByUserId User ID who resolved the feedback
980
    ResolvedByUserId *int64 `json:"resolved_by_user_id"`
981

982
    // ScreenshotData Base64 encoded screenshot
983
    ScreenshotData *string `json:"screenshot_data"`
984

985
    // ScreenshotUrl URL to stored screenshot file
986
    ScreenshotUrl *string `json:"screenshot_url"`
987

988
    // Status Current status of the feedback
989
    Status FeedbackReportStatus `json:"status"`
990

991
    // UpdatedAt When the feedback was last updated
992
    UpdatedAt time.Time `json:"updated_at"`
993

994
    // UserId User ID who submitted the feedback
995
    UserId int64 `json:"user_id"`
996
}
997

998
// FeedbackReportFeedbackType Type of feedback
999
type FeedbackReportFeedbackType string
1000

1001
// FeedbackReportStatus Current status of the feedback
1002
type FeedbackReportStatus string
1003

1004
// FeedbackSubmissionRequest defines model for FeedbackSubmissionRequest.
1005
type FeedbackSubmissionRequest struct {
1006
    // ContextData Context metadata as JSON object
1007
    ContextData *map[string]interface{} `json:"context_data,omitempty"`
1008

1009
    // FeedbackText Feedback or issue description
1010
    FeedbackText string `json:"feedback_text"`
1011

1012
    // FeedbackType Type of feedback
1013
    FeedbackType *FeedbackSubmissionRequestFeedbackType `json:"feedback_type,omitempty"`
1014

1015
    // ScreenshotData Base64 encoded screenshot (optional)
1016
    ScreenshotData *[]byte `json:"screenshot_data,omitempty"`
1017
}
1018

1019
// FeedbackSubmissionRequestFeedbackType Type of feedback
1020
type FeedbackSubmissionRequestFeedbackType string
1021

1022
// FeedbackUpdateRequest defines model for FeedbackUpdateRequest.
1023
type FeedbackUpdateRequest struct {
1024
    // AdminNotes Admin notes about this feedback
1025
    AdminNotes *string `json:"admin_notes,omitempty"`
1026

1027
    // AssignedToUserId User ID to assign this feedback to
1028
    AssignedToUserId *int64 `json:"assigned_to_user_id,omitempty"`
1029

1030
    // ResolvedAt When the feedback was resolved (use current time if status is resolved)
1031
    ResolvedAt *time.Time `json:"resolved_at,omitempty"`
1032

1033
    // ResolvedByUserId User ID who resolved the feedback
1034
    ResolvedByUserId *int64 `json:"resolved_by_user_id,omitempty"`
1035

1036
    // Status New status for the feedback
1037
    Status *FeedbackUpdateRequestStatus `json:"status,omitempty"`
1038
}
1039

1040
// FeedbackUpdateRequestStatus New status for the feedback
1041
type FeedbackUpdateRequestStatus string
1042

1043
// ForceSendNotificationResponse defines model for ForceSendNotificationResponse.
1044
type ForceSendNotificationResponse struct {
1045
    Message      *string `json:"message,omitempty"`
1046
    Notification *struct {
1047
        Status  *string `json:"status,omitempty"`
1048
        Subject *string `json:"subject,omitempty"`
1049
        Type    *string `json:"type,omitempty"`
1050
    } `json:"notification,omitempty"`
1051
    User *struct {
1052
        Email    *string `json:"email,omitempty"`
1053
        Id       *int64  `json:"id,omitempty"`
1054
        Username *string `json:"username,omitempty"`
1055
    } `json:"user,omitempty"`
1056
}
1057

1058
// GeneratingResponse defines model for GeneratingResponse.
1059
type GeneratingResponse struct {
1060
    // AiModel User's preferred AI model
1061
    AiModel *string `json:"ai_model,omitempty"`
1062

1063
    // ApiKey User's API key for the selected provider (write-only)
1064
    ApiKey  *string `json:"api_key,omitempty"`
1065
    Message *string `json:"message,omitempty"`
1066
    Status  *string `json:"status,omitempty"`
1067
}
1068

1069
// GenerationFocus defines model for GenerationFocus.
1070
type GenerationFocus struct {
1071
    // CurrentGenerationModel The AI model currently being used for generation
1072
    CurrentGenerationModel *string `json:"current_generation_model,omitempty"`
1073

1074
    // GenerationRate Average number of questions generated per minute
1075
    GenerationRate *float32 `json:"generation_rate,omitempty"`
1076

1077
    // LastGenerationTime Timestamp of the last time a question was generated
1078
    LastGenerationTime *string `json:"last_generation_time,omitempty"`
1079
}
1080

1081
// GenerationIntelligence defines model for GenerationIntelligence.
1082
type GenerationIntelligence struct {
1083
    GapAnalysis           *[]map[string]interface{} `json:"gapAnalysis,omitempty"`
1084
    GenerationSuggestions *[]map[string]interface{} `json:"generationSuggestions,omitempty"`
1085
}
1086

1087
// GoogleOAuthLoginResponse defines model for GoogleOAuthLoginResponse.
1088
type GoogleOAuthLoginResponse struct {
1089
    // AuthUrl The Google OAuth authorization URL to redirect the user to
1090
    AuthUrl string `json:"auth_url"`
1091
}
1092

1093
// HealthErrorResponse defines model for HealthErrorResponse.
1094
type HealthErrorResponse struct {
1095
    Error string `json:"error"`
1096

1097
    // Status Health status of the service
1098
    Status    HealthErrorResponseStatus `json:"status"`
1099
    Timestamp time.Time                 `json:"timestamp"`
1100
}
1101

1102
// HealthErrorResponseStatus Health status of the service
1103
type HealthErrorResponseStatus string
1104

1105
// HealthStatusResponse defines model for HealthStatusResponse.
1106
type HealthStatusResponse struct {
1107
    // Service Service name
1108
    Service string `json:"service"`
1109

1110
    // Status Health status of the service
1111
    Status HealthStatusResponseStatus `json:"status"`
1112
}
1113

1114
// HealthStatusResponseStatus Health status of the service
1115
type HealthStatusResponseStatus string
1116

1117
// Language Learning language (dynamic). Allowed values come from config.yaml language_levels keys.
1118
type Language = string
1119

1120
// LanguageInfo defines model for LanguageInfo.
1121
type LanguageInfo struct {
1122
    // Code ISO language code
1123
    Code string `json:"code"`
1124

1125
    // Name Human-readable language name
1126
    Name string `json:"name"`
1127

1128
    // TtsLocale TTS locale code for this language
1129
    TtsLocale *string `json:"tts_locale,omitempty"`
1130

1131
    // TtsVoice Default TTS voice for this language
1132
    TtsVoice *string `json:"tts_voice,omitempty"`
1133
}
1134

1135
// LanguagesResponse Array of available learning languages with codes and names
1136
type LanguagesResponse = []LanguageInfo
1137

1138
// Level Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
1139
type Level = string
1140

1141
// LevelsResponse defines model for LevelsResponse.
1142
type LevelsResponse struct {
1143
    // LevelDescriptions Mapping from level code to short label (e.g. Beginner, Intermediate)
1144
    LevelDescriptions map[string]string `json:"level_descriptions"`
1145

1146
    // Levels Array of available language proficiency levels
1147
    Levels []string `json:"levels"`
1148
}
1149

1150
// LoginRequest defines model for LoginRequest.
1151
type LoginRequest struct {
1152
    // Password Password (minimum 8 characters)
1153
    Password string `json:"password"`
1154

1155
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
1156
    Username string `json:"username"`
1157
}
1158

1159
// LoginResponse defines model for LoginResponse.
1160
type LoginResponse struct {
1161
    Message *string `json:"message,omitempty"`
1162

1163
    // RedirectUri Redirect URI for OAuth flows (optional)
1164
    RedirectUri *string `json:"redirect_uri,omitempty"`
1165
    Success     *bool   `json:"success,omitempty"`
1166
    User        *User   `json:"user,omitempty"`
1167
}
1168

1169
// MarkQuestionKnownRequest defines model for MarkQuestionKnownRequest.
1170
type MarkQuestionKnownRequest struct {
1171
    // ConfidenceLevel User's confidence level (1-5, optional)
1172
    ConfidenceLevel *int `json:"confidence_level,omitempty"`
1173
}
1174

1175
// MessageResponse defines model for MessageResponse.
1176
type MessageResponse struct {
1177
    Message string `json:"message"`
1178
}
1179

1180
// NotificationError defines model for NotificationError.
1181
type NotificationError struct {
1182
    // EmailAddress Email address that was being used
1183
    EmailAddress *string `json:"email_address"`
1184

1185
    // ErrorMessage Detailed error message
1186
    ErrorMessage *string `json:"error_message,omitempty"`
1187

1188
    // ErrorType Type of error that occurred
1189
    ErrorType *NotificationErrorErrorType `json:"error_type,omitempty"`
1190
    Id        *int64                      `json:"id,omitempty"`
1191

1192
    // NotificationType Type of notification that failed
1193
    NotificationType *NotificationErrorNotificationType `json:"notification_type,omitempty"`
1194

1195
    // OccurredAt When the error occurred
1196
    OccurredAt *string `json:"occurred_at,omitempty"`
1197

1198
    // ResolutionNotes Notes about how the error was resolved
1199
    ResolutionNotes *string `json:"resolution_notes"`
1200

1201
    // ResolvedAt When the error was resolved
1202
    ResolvedAt *string `json:"resolved_at"`
1203
    UserId     *int64  `json:"user_id"`
1204

1205
    // Username Username of the user (if available)
1206
    Username *string `json:"username,omitempty"`
1207
}
1208

1209
// NotificationErrorErrorType Type of error that occurred
1210
type NotificationErrorErrorType string
1211

1212
// NotificationErrorNotificationType Type of notification that failed
1213
type NotificationErrorNotificationType string
1214

1215
// NotificationErrorStats defines model for NotificationErrorStats.
1216
type NotificationErrorStats struct {
1217
    // ErrorsByNotificationType Breakdown of errors by notification type
1218
    ErrorsByNotificationType *map[string]int `json:"errors_by_notification_type,omitempty"`
1219

1220
    // ErrorsByType Breakdown of errors by type
1221
    ErrorsByType *map[string]int `json:"errors_by_type,omitempty"`
1222

1223
    // TotalErrors Total number of errors
1224
    TotalErrors *int `json:"total_errors,omitempty"`
1225

1226
    // UnresolvedErrors Number of unresolved errors
1227
    UnresolvedErrors *int `json:"unresolved_errors,omitempty"`
1228
}
1229

1230
// NotificationStats defines model for NotificationStats.
1231
type NotificationStats struct {
1232
    // NotificationsByType Breakdown of notifications by type
1233
    NotificationsByType *map[string]int `json:"notifications_by_type,omitempty"`
1234

1235
    // SentThisWeek Number of notifications sent this week
1236
    SentThisWeek *int `json:"sent_this_week,omitempty"`
1237

1238
    // SentToday Number of notifications sent today
1239
    SentToday *int `json:"sent_today,omitempty"`
1240

1241
    // SuccessRate Success rate as a percentage (0-1)
1242
    SuccessRate *float32 `json:"success_rate,omitempty"`
1243

1244
    // TotalFailed Total number of notifications that failed
1245
    TotalFailed *int `json:"total_failed,omitempty"`
1246

1247
    // TotalSent Total number of notifications sent
1248
    TotalSent *int `json:"total_sent,omitempty"`
1249
}
1250

1251
// PaginationInfo defines model for PaginationInfo.
1252
type PaginationInfo struct {
1253
    // Page Current page number
1254
    Page int `json:"page"`
1255

1256
    // PageSize Number of items per page
1257
    PageSize int `json:"page_size"`
1258

1259
    // Total Total number of items
1260
    Total int `json:"total"`
1261

1262
    // TotalPages Total number of pages
1263
    TotalPages int `json:"total_pages"`
1264
}
1265

1266
// PasswordResetRequest defines model for PasswordResetRequest.
1267
type PasswordResetRequest struct {
1268
    // NewPassword New password (minimum 8 characters)
1269
    NewPassword string `json:"new_password"`
1270
}
1271

1272
// PerformanceMetrics defines model for PerformanceMetrics.
1273
type PerformanceMetrics struct {
1274
    AverageResponseTimeMs *float32 `json:"average_response_time_ms,omitempty"`
1275
    CorrectAttempts       *int     `json:"correct_attempts,omitempty"`
1276
    LastUpdated           *string  `json:"last_updated,omitempty"`
1277
    TotalAttempts         *int     `json:"total_attempts,omitempty"`
1278
}
1279

1280
// PriorityAnalytics defines model for PriorityAnalytics.
1281
type PriorityAnalytics struct {
1282
    Distribution *struct {
1283
        Average *float32 `json:"average,omitempty"`
1284
        High    *int     `json:"high,omitempty"`
1285
        Low     *int     `json:"low,omitempty"`
1286
        Medium  *int     `json:"medium,omitempty"`
1287
    } `json:"distribution,omitempty"`
1288
}
1289

1290
// PriorityInsights defines model for PriorityInsights.
1291
type PriorityInsights struct {
1292
    // HighPriorityQuestions Number of high-priority questions
1293
    HighPriorityQuestions *int `json:"high_priority_questions,omitempty"`
1294

1295
    // LowPriorityQuestions Number of low-priority questions
1296
    LowPriorityQuestions *int `json:"low_priority_questions,omitempty"`
1297

1298
    // MediumPriorityQuestions Number of medium-priority questions
1299
    MediumPriorityQuestions *int `json:"medium_priority_questions,omitempty"`
1300

1301
    // TotalQuestionsInQueue Total number of questions waiting to be processed
1302
    TotalQuestionsInQueue *int `json:"total_questions_in_queue,omitempty"`
1303
}
1304

1305
// Question defines model for Question.
1306
type Question struct {
1307
    // ConfidenceLevel Confidence level when question was marked as known (1-5)
1308
    ConfidenceLevel *int `json:"confidence_level,omitempty"`
1309

1310
    // Content All question types now use multiple choice format with 4 options. Either 'question' or 'sentence' must be present depending on question type.
1311
    Content *QuestionContent `json:"content,omitempty"`
1312

1313
    // CorrectAnswer Index of the correct answer in the options array (0-based)
1314
    CorrectAnswer *int `json:"correct_answer,omitempty"`
1315

1316
    // CorrectCount Number of times this question was answered correctly
1317
    CorrectCount *int    `json:"correct_count,omitempty"`
1318
    CreatedAt    *string `json:"created_at,omitempty"`
1319

1320
    // DifficultyModifier Difficulty modifier for the question (e.g., basic, intermediate)
1321
    DifficultyModifier *string  `json:"difficulty_modifier,omitempty"`
1322
    DifficultyScore    *float32 `json:"difficulty_score,omitempty"`
1323
    Explanation        *string  `json:"explanation,omitempty"`
1324

1325
    // GrammarFocus Grammar focus area for the question (e.g., present_perfect, conditionals)
1326
    GrammarFocus *string `json:"grammar_focus,omitempty"`
1327
    Id           *int64  `json:"id,omitempty"`
1328

1329
    // IncorrectCount Number of times this question was answered incorrectly
1330
    IncorrectCount *int `json:"incorrect_count,omitempty"`
1331

1332
    // Language Learning language (dynamic). Allowed values come from config.yaml language_levels keys.
1333
    Language *Language `json:"language,omitempty"`
1334

1335
    // Level Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
1336
    Level *Level `json:"level,omitempty"`
1337

1338
    // Reporters Comma-separated list of usernames who reported this question
1339
    Reporters *string `json:"reporters,omitempty"`
1340

1341
    // Scenario Scenario context for the question (e.g., at_the_airport, in_a_restaurant)
1342
    Scenario *string         `json:"scenario,omitempty"`
1343
    Status   *QuestionStatus `json:"status,omitempty"`
1344

1345
    // StyleModifier Style modifier for the question (e.g., conversational, formal)
1346
    StyleModifier *string `json:"style_modifier,omitempty"`
1347

1348
    // TimeContext Time context for the question (e.g., morning_routine, workday)
1349
    TimeContext *string `json:"time_context,omitempty"`
1350

1351
    // TopicCategory General topic category for question context (e.g., daily_life, travel, work)
1352
    TopicCategory *string `json:"topic_category,omitempty"`
1353

1354
    // TotalResponses Total number of responses to this question (used for 'Shown' in the UI)
1355
    TotalResponses *int          `json:"total_responses,omitempty"`
1356
    Type           *QuestionType `json:"type,omitempty"`
1357

1358
    // UserCount Number of users assigned to this question
1359
    UserCount *int `json:"user_count,omitempty"`
1360

1361
    // VocabularyDomain Vocabulary domain for the question (e.g., food_and_dining, transportation)
1362
    VocabularyDomain *string `json:"vocabulary_domain,omitempty"`
1363
}
1364

1365
// QuestionAssignedUsersResponse defines model for QuestionAssignedUsersResponse.
1366
type QuestionAssignedUsersResponse struct {
1367
    // TotalCount Total number of users assigned to this question
1368
    TotalCount int           `json:"total_count"`
1369
    Users      []UserProfile `json:"users"`
1370
}
1371

1372
// QuestionContent All question types now use multiple choice format with 4 options. Either 'question' or 'sentence' must be present depending on question type.
1373
type QuestionContent struct {
1374
    // Hint Optional hint for fill-in-blank questions
1375
    Hint    *string  `json:"hint,omitempty"`
1376
    Options []string `json:"options"`
1377

1378
    // Passage Only present for reading comprehension questions
1379
    Passage *string `json:"passage,omitempty"`
1380

1381
    // Question Question text (required for most question types, optional for fill_blank and vocabulary)
1382
    Question *string `json:"question,omitempty"`
1383

1384
    // Sentence Sentence text used for vocabulary questions (context sentence) and fill_blank questions (sentence with blank)
1385
    Sentence *string `json:"sentence,omitempty"`
1386

1387
    // Topic Specific topic for the question (e.g., "reading-comprehension-past-continuous", "clothing", "culture")
1388
    Topic *string `json:"topic,omitempty"`
1389
}
1390

1391
// QuestionStats defines model for QuestionStats.
1392
type QuestionStats struct {
1393
    // QuestionsByLanguage Breakdown of questions by language
1394
    QuestionsByLanguage *map[string]int `json:"questions_by_language,omitempty"`
1395

1396
    // QuestionsByLevel Breakdown of questions by level
1397
    QuestionsByLevel *map[string]int `json:"questions_by_level,omitempty"`
1398

1399
    // QuestionsByType Breakdown of questions by type
1400
    QuestionsByType *map[string]int `json:"questions_by_type,omitempty"`
1401

1402
    // TotalQuestions Total number of questions
1403
    TotalQuestions *int `json:"total_questions,omitempty"`
1404

1405
    // TotalResponses Total number of responses
1406
    TotalResponses *int `json:"total_responses,omitempty"`
1407
}
1408

1409
// QuestionStatus defines model for QuestionStatus.
1410
type QuestionStatus string
1411

1412
// QuestionType defines model for QuestionType.
1413
type QuestionType string
1414

1415
// QuizChatRequest defines model for QuizChatRequest.
1416
type QuizChatRequest struct {
1417
    AnswerContext *AnswerResponse `json:"answer_context,omitempty"`
1418

1419
    // ConversationHistory Previous messages in the conversation
1420
    ConversationHistory *[]ChatMessage `json:"conversation_history,omitempty"`
1421
    Question            Question       `json:"question"`
1422

1423
    // UserMessage The user's message to the AI tutor.
1424
    UserMessage string `json:"user_message"`
1425
}
1426

1427
// ReportQuestionRequest defines model for ReportQuestionRequest.
1428
type ReportQuestionRequest struct {
1429
    // ReportReason Optional explanation for why the question is being reported
1430
    ReportReason *string `json:"report_reason,omitempty"`
1431
}
1432

1433
// ReportedQuestionsStats defines model for ReportedQuestionsStats.
1434
type ReportedQuestionsStats struct {
1435
    ReportedByLanguage *map[string]int `json:"reported_by_language,omitempty"`
1436
    ReportedByLevel    *map[string]int `json:"reported_by_level,omitempty"`
1437
    ReportedByType     *map[string]int `json:"reported_by_type,omitempty"`
1438
    TotalReported      *int            `json:"total_reported,omitempty"`
1439
}
1440

1441
// Role defines model for Role.
1442
type Role struct {
1443
    // CreatedAt When the role was created
1444
    CreatedAt string `json:"created_at"`
1445

1446
    // Description Role description
1447
    Description string `json:"description"`
1448

1449
    // Id Role ID
1450
    Id int64 `json:"id"`
1451

1452
    // Name Role name (e.g., "user", "admin")
1453
    Name string `json:"name"`
1454

1455
    // UpdatedAt When the role was last updated
1456
    UpdatedAt string `json:"updated_at"`
1457
}
1458

1459
// SentNotification defines model for SentNotification.
1460
type SentNotification struct {
1461
    // EmailAddress Email address the notification was sent to
1462
    EmailAddress *string `json:"email_address,omitempty"`
1463

1464
    // ErrorMessage Error message if the notification failed
1465
    ErrorMessage *string `json:"error_message"`
1466
    Id           *int64  `json:"id,omitempty"`
1467

1468
    // NotificationType Type of notification
1469
    NotificationType *SentNotificationNotificationType `json:"notification_type,omitempty"`
1470

1471
    // RetryCount Number of times the notification was retried
1472
    RetryCount *int `json:"retry_count,omitempty"`
1473

1474
    // SentAt When the notification was sent
1475
    SentAt *string `json:"sent_at,omitempty"`
1476

1477
    // Status Status of the notification
1478
    Status *SentNotificationStatus `json:"status,omitempty"`
1479

1480
    // Subject Subject line of the email
1481
    Subject *string `json:"subject,omitempty"`
1482

1483
    // TemplateName Template used for the notification
1484
    TemplateName *string `json:"template_name,omitempty"`
1485
    UserId       *int64  `json:"user_id,omitempty"`
1486

1487
    // Username Username of the user
1488
    Username *string `json:"username,omitempty"`
1489
}
1490

1491
// SentNotificationNotificationType Type of notification
1492
type SentNotificationNotificationType string
1493

1494
// SentNotificationStatus Status of the notification
1495
type SentNotificationStatus string
1496

1497
// ServiceUsageStatsResponse defines model for ServiceUsageStatsResponse.
1498
type ServiceUsageStatsResponse struct {
1499
    Data []struct {
1500
        // CharactersUsed Number of characters processed
1501
        CharactersUsed *int `json:"characters_used,omitempty"`
1502

1503
        // Month First day of the month (YYYY-MM)
1504
        Month *string `json:"month,omitempty"`
1505

1506
        // Quota Monthly quota for this service
1507
        Quota *int `json:"quota,omitempty"`
1508

1509
        // RequestsMade Number of requests made
1510
        RequestsMade *int `json:"requests_made,omitempty"`
1511

1512
        // UsageType Type of usage (e.g., "translation")
1513
        UsageType *string `json:"usage_type,omitempty"`
1514
    } `json:"data"`
1515

1516
    // Service Name of the service
1517
    Service string `json:"service"`
1518
}
1519

1520
// ServiceVersion defines model for ServiceVersion.
1521
type ServiceVersion struct {
1522
    // BuildTime Build timestamp (ISO8601)
1523
    BuildTime string `json:"buildTime"`
1524

1525
    // Commit Git commit hash
1526
    Commit string `json:"commit"`
1527

1528
    // Service Service name (e.g., 'backend', 'worker')
1529
    Service string `json:"service"`
1530

1531
    // Version Version string (e.g., git tag or 'dev')
1532
    Version string `json:"version"`
1533
}
1534

1535
// SignupStatusResponse defines model for SignupStatusResponse.
1536
type SignupStatusResponse struct {
1537
    // SignupsDisabled Whether user signups are currently disabled
1538
    SignupsDisabled bool `json:"signups_disabled"`
1539
}
1540

1541
// Snippet defines model for Snippet.
1542
type Snippet struct {
1543
    Context   *string    `json:"context"`
1544
    CreatedAt *time.Time `json:"created_at,omitempty"`
1545

1546
    // DifficultyLevel CEFR level (A1, A2, B1, B2, C1, C2)
1547
    DifficultyLevel *string `json:"difficulty_level"`
1548
    Id              *int64  `json:"id,omitempty"`
1549
    OriginalText    *string `json:"original_text,omitempty"`
1550
    QuestionId      *int64  `json:"question_id"`
1551

1552
    // SectionId ID of the story section where this snippet was created
1553
    SectionId      *int64  `json:"section_id"`
1554
    SourceLanguage *string `json:"source_language,omitempty"`
1555

1556
    // StoryId ID of the story where this snippet was created
1557
    StoryId        *int64     `json:"story_id"`
1558
    TargetLanguage *string    `json:"target_language,omitempty"`
1559
    TranslatedText *string    `json:"translated_text,omitempty"`
1560
    UpdatedAt      *time.Time `json:"updated_at,omitempty"`
1561
    UserId         *int64     `json:"user_id,omitempty"`
1562
}
1563

1564
// SnippetList defines model for SnippetList.
1565
type SnippetList struct {
1566
    // Limit Number of snippets returned
1567
    Limit *int `json:"limit,omitempty"`
1568

1569
    // Offset Number of snippets skipped
1570
    Offset *int `json:"offset,omitempty"`
1571

1572
    // Query The search query that was used (if any)
1573
    Query    *string    `json:"query"`
1574
    Snippets *[]Snippet `json:"snippets,omitempty"`
1575

1576
    // Total Total number of snippets matching the query
1577
    Total *int `json:"total,omitempty"`
1578
}
1579

1580
// SnippetsResponse defines model for SnippetsResponse.
1581
type SnippetsResponse struct {
1582
    Snippets []Snippet `json:"snippets"`
1583
}
1584

1585
// Story defines model for Story.
1586
type Story struct {
1587
    AuthorStyle *string `json:"author_style"`
1588

1589
    // AutoGenerationPaused When true, the worker will skip automatic section generation for this story
1590
    AutoGenerationPaused   *bool                       `json:"auto_generation_paused,omitempty"`
1591
    CharacterNames         *string                     `json:"character_names"`
1592
    CreatedAt              *time.Time                  `json:"created_at,omitempty"`
1593
    CustomInstructions     *string                     `json:"custom_instructions"`
1594
    ExtraGenerationsToday  *int                        `json:"extra_generations_today,omitempty"`
1595
    Genre                  *string                     `json:"genre"`
1596
    Id                     *int64                      `json:"id,omitempty"`
1597
    Language               *string                     `json:"language,omitempty"`
1598
    LastSectionGeneratedAt *time.Time                  `json:"last_section_generated_at"`
1599
    SectionLengthOverride  *StorySectionLengthOverride `json:"section_length_override,omitempty"`
1600
    Status                 *StoryStatus                `json:"status,omitempty"`
1601
    Subject                *string                     `json:"subject"`
1602
    TimePeriod             *string                     `json:"time_period"`
1603
    Title                  *string                     `json:"title,omitempty"`
1604
    Tone                   *string                     `json:"tone"`
1605
    UpdatedAt              *time.Time                  `json:"updated_at,omitempty"`
1606
    UserId                 *int64                      `json:"user_id,omitempty"`
1607
}
1608

1609
// StorySectionLengthOverride defines model for Story.SectionLengthOverride.
1610
type StorySectionLengthOverride string
1611

1612
// StoryStatus defines model for Story.Status.
1613
type StoryStatus string
1614

1615
// StorySection defines model for StorySection.
1616
type StorySection struct {
1617
    Content        *string             `json:"content,omitempty"`
1618
    GeneratedAt    *time.Time          `json:"generated_at,omitempty"`
1619
    GenerationDate *openapi_types.Date `json:"generation_date,omitempty"`
1620
    Id             *int64              `json:"id,omitempty"`
1621
    LanguageLevel  *string             `json:"language_level,omitempty"`
1622
    SectionNumber  *int                `json:"section_number,omitempty"`
1623
    StoryId        *int64              `json:"story_id,omitempty"`
1624
    WordCount      *int                `json:"word_count,omitempty"`
1625
}
1626

1627
// StorySectionQuestion defines model for StorySectionQuestion.
1628
type StorySectionQuestion struct {
1629
    CorrectAnswerIndex *int       `json:"correct_answer_index,omitempty"`
1630
    CreatedAt          *time.Time `json:"created_at,omitempty"`
1631
    Explanation        *string    `json:"explanation"`
1632
    Id                 *int64     `json:"id,omitempty"`
1633
    Options            *[]string  `json:"options,omitempty"`
1634
    QuestionText       *string    `json:"question_text,omitempty"`
1635
    SectionId          *int64     `json:"section_id,omitempty"`
1636
}
1637

1638
// StorySectionWithQuestions defines model for StorySectionWithQuestions.
1639
type StorySectionWithQuestions struct {
1640
    Content        *string                 `json:"content,omitempty"`
1641
    GeneratedAt    *time.Time              `json:"generated_at,omitempty"`
1642
    GenerationDate *openapi_types.Date     `json:"generation_date,omitempty"`
1643
    Id             *int64                  `json:"id,omitempty"`
1644
    LanguageLevel  *string                 `json:"language_level,omitempty"`
1645
    Questions      *[]StorySectionQuestion `json:"questions,omitempty"`
1646
    SectionNumber  *int                    `json:"section_number,omitempty"`
1647
    StoryId        *int64                  `json:"story_id,omitempty"`
1648
    WordCount      *int                    `json:"word_count,omitempty"`
1649
}
1650

1651
// StoryWithSections defines model for StoryWithSections.
1652
type StoryWithSections struct {
1653
    AuthorStyle *string `json:"author_style"`
1654

1655
    // AutoGenerationPaused When true, the worker will skip automatic section generation for this story
1656
    AutoGenerationPaused   *bool                                   `json:"auto_generation_paused,omitempty"`
1657
    CharacterNames         *string                                 `json:"character_names"`
1658
    CreatedAt              *time.Time                              `json:"created_at,omitempty"`
1659
    CustomInstructions     *string                                 `json:"custom_instructions"`
1660
    ExtraGenerationsToday  *int                                    `json:"extra_generations_today,omitempty"`
1661
    Genre                  *string                                 `json:"genre"`
1662
    Id                     *int64                                  `json:"id,omitempty"`
1663
    Language               *string                                 `json:"language,omitempty"`
1664
    LastSectionGeneratedAt *time.Time                              `json:"last_section_generated_at"`
1665
    SectionLengthOverride  *StoryWithSectionsSectionLengthOverride `json:"section_length_override,omitempty"`
1666
    Sections               *[]StorySection                         `json:"sections,omitempty"`
1667
    Status                 *StoryWithSectionsStatus                `json:"status,omitempty"`
1668
    Subject                *string                                 `json:"subject"`
1669
    TimePeriod             *string                                 `json:"time_period"`
1670
    Title                  *string                                 `json:"title,omitempty"`
1671
    Tone                   *string                                 `json:"tone"`
1672
    UpdatedAt              *time.Time                              `json:"updated_at,omitempty"`
1673
    UserId                 *int64                                  `json:"user_id,omitempty"`
1674
}
1675

1676
// StoryWithSectionsSectionLengthOverride defines model for StoryWithSections.SectionLengthOverride.
1677
type StoryWithSectionsSectionLengthOverride string
1678

1679
// StoryWithSectionsStatus defines model for StoryWithSections.Status.
1680
type StoryWithSectionsStatus string
1681

1682
// SuccessResponse defines model for SuccessResponse.
1683
type SuccessResponse struct {
1684
    Message *string `json:"message,omitempty"`
1685
    Success bool    `json:"success"`
1686
}
1687

1688
// SystemHealthAnalytics defines model for SystemHealthAnalytics.
1689
type SystemHealthAnalytics struct {
1690
    BackgroundJobs *map[string]interface{} `json:"backgroundJobs,omitempty"`
1691
    Performance    *map[string]interface{} `json:"performance,omitempty"`
1692
}
1693

1694
// TTSRequest defines model for TTSRequest.
1695
type TTSRequest struct {
1696
    // Input The text to convert to speech
1697
    Input string `json:"input"`
1698

1699
    // Model The TTS model to use
1700
    Model *string `json:"model,omitempty"`
1701

1702
    // StreamFormat The format for streaming audio data
1703
    StreamFormat *TTSRequestStreamFormat `json:"stream_format,omitempty"`
1704

1705
    // Voice The voice to use for speech generation
1706
    Voice *string `json:"voice,omitempty"`
1707
}
1708

1709
// TTSRequestStreamFormat The format for streaming audio data
1710
type TTSRequestStreamFormat string
1711

1712
// TTSResponse defines model for TTSResponse.
1713
type TTSResponse struct {
1714
    // Audio Base64 encoded audio chunk (for type=audio)
1715
    Audio *string `json:"audio,omitempty"`
1716

1717
    // Error Error message (for type=error)
1718
    Error *string `json:"error,omitempty"`
1719

1720
    // Type The type of SSE event
1721
    Type *TTSResponseType `json:"type,omitempty"`
1722

1723
    // Usage Usage statistics (for type=usage)
1724
    Usage *struct {
1725
        // InputTokens Number of input tokens processed
1726
        InputTokens *int `json:"input_tokens,omitempty"`
1727

1728
        // OutputTokens Number of output tokens generated
1729
        OutputTokens *int `json:"output_tokens,omitempty"`
1730

1731
        // TotalTokens Total tokens used
1732
        TotalTokens *int `json:"total_tokens,omitempty"`
1733
    } `json:"usage,omitempty"`
1734
}
1735

1736
// TTSResponseType The type of SSE event
1737
type TTSResponseType string
1738

1739
// TTSStreamInitResponse defines model for TTSStreamInitResponse.
1740
type TTSStreamInitResponse struct {
1741
    StreamId string  `json:"stream_id"`
1742
    Token    *string `json:"token"`
1743
}
1744

1745
// TestAIRequest defines model for TestAIRequest.
1746
type TestAIRequest struct {
1747
    // ApiKey API key for the provider. If not provided, the server will try to use a saved key.
1748
    ApiKey *string `json:"api_key"`
1749

1750
    // Model AI model code (e.g., "llama3", "gpt-4")
1751
    Model string `json:"model"`
1752

1753
    // Provider AI provider code (e.g., "ollama", "openai")
1754
    Provider string `json:"provider"`
1755
}
1756

1757
// ToggleAutoGenerationRequest defines model for ToggleAutoGenerationRequest.
1758
type ToggleAutoGenerationRequest struct {
1759
    // Paused Whether to pause (true) or resume (false) auto-generation
1760
    Paused bool `json:"paused"`
1761
}
1762

1763
// ToggleAutoGenerationResponse defines model for ToggleAutoGenerationResponse.
1764
type ToggleAutoGenerationResponse struct {
1765
    AutoGenerationPaused *bool   `json:"auto_generation_paused,omitempty"`
1766
    Message              *string `json:"message,omitempty"`
1767
}
1768

1769
// TranslateRequest defines model for TranslateRequest.
1770
type TranslateRequest struct {
1771
    // SourceLanguage Source language code (optional - will be auto-detected if not provided)
1772
    SourceLanguage *string `json:"source_language,omitempty"`
1773

1774
    // TargetLanguage Target language code (e.g., 'en', 'es', 'fr')
1775
    TargetLanguage string `json:"target_language"`
1776

1777
    // Text Text to translate
1778
    Text string `json:"text"`
1779
}
1780

1781
// TranslateResponse defines model for TranslateResponse.
1782
type TranslateResponse struct {
1783
    // Confidence Translation confidence score (if available from provider)
1784
    Confidence *float32 `json:"confidence,omitempty"`
1785

1786
    // SourceLanguage Detected or provided source language code
1787
    SourceLanguage string `json:"source_language"`
1788

1789
    // TargetLanguage Target language code that was requested
1790
    TargetLanguage string `json:"target_language"`
1791

1792
    // TranslatedText The translated text
1793
    TranslatedText string `json:"translated_text"`
1794
}
1795

1796
// TranslationPracticeGenerateRequest defines model for TranslationPracticeGenerateRequest.
1797
type TranslationPracticeGenerateRequest struct {
1798
    // Direction Translation direction
1799
    Direction TranslationPracticeGenerateRequestDirection `json:"direction"`
1800

1801
    // Language Learning language code
1802
    Language string `json:"language"`
1803

1804
    // Level Language level (e.g., A1, A2, B1, B2)
1805
    Level string `json:"level"`
1806

1807
    // Topic Optional topic or keywords for sentence generation
1808
    Topic *string `json:"topic,omitempty"`
1809
}
1810

1811
// TranslationPracticeGenerateRequestDirection Translation direction
1812
type TranslationPracticeGenerateRequestDirection string
1813

1814
// TranslationPracticeHistoryResponse defines model for TranslationPracticeHistoryResponse.
1815
type TranslationPracticeHistoryResponse struct {
1816
    // Limit Number of sessions returned (page size)
1817
    Limit int `json:"limit"`
1818

1819
    // Offset Number of sessions skipped
1820
    Offset int `json:"offset"`
1821

1822
    // Sessions List of practice sessions
1823
    Sessions []TranslationPracticeSessionResponse `json:"sessions"`
1824

1825
    // Total Total number of sessions matching the query (before pagination)
1826
    Total int `json:"total"`
1827
}
1828

1829
// TranslationPracticeSentenceResponse defines model for TranslationPracticeSentenceResponse.
1830
type TranslationPracticeSentenceResponse struct {
1831
    // CreatedAt When the sentence was created
1832
    CreatedAt time.Time `json:"created_at"`
1833

1834
    // Id Sentence ID
1835
    Id int `json:"id"`
1836

1837
    // LanguageLevel Language level
1838
    LanguageLevel string `json:"language_level"`
1839

1840
    // SentenceText The sentence text to translate
1841
    SentenceText string `json:"sentence_text"`
1842

1843
    // SourceId ID of the source (if applicable)
1844
    SourceId *int `json:"source_id,omitempty"`
1845

1846
    // SourceLanguage Source language code
1847
    SourceLanguage string `json:"source_language"`
1848

1849
    // SourceType Source type (ai_generated, story_section, vocabulary_question, etc.)
1850
    SourceType string `json:"source_type"`
1851

1852
    // TargetLanguage Target language code
1853
    TargetLanguage string `json:"target_language"`
1854

1855
    // Topic Topic or keywords (if applicable)
1856
    Topic *string `json:"topic,omitempty"`
1857
}
1858

1859
// TranslationPracticeSessionResponse defines model for TranslationPracticeSessionResponse.
1860
type TranslationPracticeSessionResponse struct {
1861
    // AiFeedback AI feedback on the translation
1862
    AiFeedback string `json:"ai_feedback"`
1863

1864
    // AiScore AI score from 0 to 5
1865
    AiScore *float32 `json:"ai_score,omitempty"`
1866

1867
    // CreatedAt When the session was created
1868
    CreatedAt time.Time `json:"created_at"`
1869

1870
    // Id Session ID
1871
    Id int `json:"id"`
1872

1873
    // OriginalSentence The original sentence
1874
    OriginalSentence string `json:"original_sentence"`
1875

1876
    // SentenceId Sentence ID
1877
    SentenceId int `json:"sentence_id"`
1878

1879
    // TranslationDirection Translation direction
1880
    TranslationDirection string `json:"translation_direction"`
1881

1882
    // UserTranslation The user's translation
1883
    UserTranslation string `json:"user_translation"`
1884
}
1885

1886
// TranslationPracticeStatsResponse defines model for TranslationPracticeStatsResponse.
1887
type TranslationPracticeStatsResponse struct {
1888
    // AverageScore Average AI score
1889
    AverageScore *float32 `json:"average_score,omitempty"`
1890

1891
    // ExcellentCount Number of sessions with score >= 4.0
1892
    ExcellentCount *int `json:"excellent_count,omitempty"`
1893

1894
    // GoodCount Number of sessions with score 3.0-3.9
1895
    GoodCount *int `json:"good_count,omitempty"`
1896

1897
    // MaxScore Maximum AI score
1898
    MaxScore *float32 `json:"max_score,omitempty"`
1899

1900
    // MinScore Minimum AI score
1901
    MinScore *float32 `json:"min_score,omitempty"`
1902

1903
    // NeedsImprovementCount Number of sessions with score < 3.0
1904
    NeedsImprovementCount *int `json:"needs_improvement_count,omitempty"`
1905

1906
    // TotalSessions Total number of practice sessions
1907
    TotalSessions *int `json:"total_sessions,omitempty"`
1908
}
1909

1910
// TranslationPracticeSubmitRequest defines model for TranslationPracticeSubmitRequest.
1911
type TranslationPracticeSubmitRequest struct {
1912
    // OriginalSentence The original sentence to translate
1913
    OriginalSentence string `json:"original_sentence"`
1914

1915
    // SentenceId ID of the sentence being translated
1916
    SentenceId int `json:"sentence_id"`
1917

1918
    // TranslationDirection Translation direction
1919
    TranslationDirection TranslationPracticeSubmitRequestTranslationDirection `json:"translation_direction"`
1920

1921
    // UserTranslation The user's translation
1922
    UserTranslation string `json:"user_translation"`
1923
}
1924

1925
// TranslationPracticeSubmitRequestTranslationDirection Translation direction
1926
type TranslationPracticeSubmitRequestTranslationDirection string
1927

1928
// UpdateConversationRequest defines model for UpdateConversationRequest.
1929
type UpdateConversationRequest struct {
1930
    // Title New title for the conversation
1931
    Title string `json:"title"`
1932
}
1933

1934
// UpdateSnippetRequest defines model for UpdateSnippetRequest.
1935
type UpdateSnippetRequest struct {
1936
    // Context User-provided context or notes about this snippet
1937
    Context *string `json:"context"`
1938

1939
    // OriginalText The original text/word to save
1940
    OriginalText *string `json:"original_text,omitempty"`
1941

1942
    // SourceLanguage ISO language code of the source text
1943
    SourceLanguage *string `json:"source_language,omitempty"`
1944

1945
    // TargetLanguage ISO language code of the target translation
1946
    TargetLanguage *string `json:"target_language,omitempty"`
1947

1948
    // TranslatedText The translated text
1949
    TranslatedText *string `json:"translated_text,omitempty"`
1950
}
1951

1952
// UsageStatsResponse defines model for UsageStatsResponse.
1953
type UsageStatsResponse struct {
1954
    // CacheStats Cache performance statistics across all services
1955
    CacheStats *struct {
1956
        // CacheHitRate Cache hit rate as a percentage
1957
        CacheHitRate *float32 `json:"cache_hit_rate,omitempty"`
1958

1959
        // TotalCacheHitsCharacters Total characters served from cache
1960
        TotalCacheHitsCharacters *int `json:"total_cache_hits_characters,omitempty"`
1961

1962
        // TotalCacheHitsRequests Total number of cache hit requests
1963
        TotalCacheHitsRequests *int `json:"total_cache_hits_requests,omitempty"`
1964

1965
        // TotalCacheMissesRequests Total number of cache miss requests
1966
        TotalCacheMissesRequests *int `json:"total_cache_misses_requests,omitempty"`
1967
    } `json:"cache_stats,omitempty"`
1968

1969
    // MonthlyTotals Monthly totals organized by month (YYYY-MM) and service
1970
    MonthlyTotals map[string]map[string]struct {
1971
        TotalCharacters *int `json:"total_characters,omitempty"`
1972
        TotalRequests   *int `json:"total_requests,omitempty"`
1973
    } `json:"monthly_totals"`
1974

1975
    // Services List of service names
1976
    Services []string `json:"services"`
1977

1978
    // UsageStats Usage statistics organized by service, month (YYYY-MM), and usage type
1979
    UsageStats map[string]map[string]struct {
1980
        CharactersUsed *int `json:"characters_used,omitempty"`
1981
        Quota          *int `json:"quota,omitempty"`
1982
        RequestsMade   *int `json:"requests_made,omitempty"`
1983
    } `json:"usage_stats"`
1984
}
1985

1986
// User defines model for User.
1987
type User struct {
1988
    // AiEnabled Whether AI features are enabled for this user
1989
    AiEnabled    *bool   `json:"ai_enabled"`
1990
    AiModel      *string `json:"ai_model"`
1991
    AiProvider   *string `json:"ai_provider"`
1992
    CreatedAt    *string `json:"created_at,omitempty"`
1993
    CurrentLevel *string `json:"current_level"`
1994
    Email        *string `json:"email"`
1995

1996
    // HasApiKey Whether the user has a valid API key saved for their current AI provider
1997
    HasApiKey *bool  `json:"has_api_key,omitempty"`
1998
    Id        *int64 `json:"id,omitempty"`
1999

2000
    // IsPaused Whether the user is paused (question generation disabled)
2001
    IsPaused          *bool   `json:"is_paused,omitempty"`
2002
    LastActive        *string `json:"last_active"`
2003
    PreferredLanguage *string `json:"preferred_language"`
2004

2005
    // Roles List of roles assigned to the user
2006
    Roles    *[]Role `json:"roles,omitempty"`
2007
    Timezone *string `json:"timezone"`
2008

2009
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
2010
    Username *string `json:"username,omitempty"`
2011

2012
    // WordOfDayEmailEnabled Whether the user has enabled Word of the Day emails
2013
    WordOfDayEmailEnabled *bool `json:"word_of_day_email_enabled,omitempty"`
2014
}
2015

2016
// UserCreateRequest defines model for UserCreateRequest.
2017
type UserCreateRequest struct {
2018
    // AiEnabled Whether AI features are enabled for this user
2019
    AiEnabled *bool `json:"ai_enabled,omitempty"`
2020

2021
    // CurrentLevel Current proficiency level
2022
    CurrentLevel *string `json:"current_level,omitempty"`
2023

2024
    // Email Email address
2025
    Email *openapi_types.Email `json:"email,omitempty"`
2026

2027
    // Password Password (minimum 8 characters)
2028
    Password string `json:"password"`
2029

2030
    // PreferredLanguage Preferred learning language
2031
    PreferredLanguage *string `json:"preferred_language,omitempty"`
2032

2033
    // Timezone Timezone (e.g., "UTC", "America/New_York")
2034
    Timezone *string `json:"timezone,omitempty"`
2035

2036
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
2037
    Username string `json:"username"`
2038
}
2039

2040
// UserIdRequest defines model for UserIdRequest.
2041
type UserIdRequest struct {
2042
    // UserId ID of the user
2043
    UserId int64 `json:"user_id"`
2044
}
2045

2046
// UserLearningPreferences defines model for UserLearningPreferences.
2047
type UserLearningPreferences struct {
2048
    // DailyGoal User-configurable number of daily questions
2049
    DailyGoal *int `json:"daily_goal,omitempty"`
2050

2051
    // DailyReminderEnabled Whether to receive daily reminder emails
2052
    DailyReminderEnabled bool `json:"daily_reminder_enabled"`
2053

2054
    // FocusOnWeakAreas Whether to focus on weak areas
2055
    FocusOnWeakAreas bool `json:"focus_on_weak_areas"`
2056

2057
    // FreshQuestionRatio Ratio of fresh (never seen) questions to show (0-1)
2058
    FreshQuestionRatio float32 `json:"fresh_question_ratio"`
2059

2060
    // KnownQuestionPenalty Penalty multiplier for questions marked as known (0-1)
2061
    KnownQuestionPenalty float32 `json:"known_question_penalty"`
2062

2063
    // ReviewIntervalDays Days between reviews of known questions
2064
    ReviewIntervalDays int `json:"review_interval_days"`
2065

2066
    // TtsVoice Preferred TTS voice (e.g., it-IT-IsabellaNeural)
2067
    TtsVoice *string `json:"tts_voice,omitempty"`
2068

2069
    // WeakAreaBoost Multiplier for weak area questions
2070
    WeakAreaBoost float32 `json:"weak_area_boost"`
2071
}
2072

2073
// UserPerformanceAnalytics defines model for UserPerformanceAnalytics.
2074
type UserPerformanceAnalytics struct {
2075
    LearningPreferences *map[string]interface{}   `json:"learningPreferences,omitempty"`
2076
    WeakAreas           *[]map[string]interface{} `json:"weakAreas,omitempty"`
2077
}
2078

2079
// UserProfile defines model for UserProfile.
2080
type UserProfile struct {
2081
    // AiEnabled Whether AI features are enabled for this user
2082
    AiEnabled    *bool   `json:"ai_enabled"`
2083
    CreatedAt    *string `json:"created_at,omitempty"`
2084
    CurrentLevel *string `json:"current_level,omitempty"`
2085
    Email        *string `json:"email"`
2086
    Id           *int64  `json:"id,omitempty"`
2087

2088
    // IsPaused Whether the user is paused (question generation disabled)
2089
    IsPaused          *bool   `json:"is_paused,omitempty"`
2090
    LastActive        *string `json:"last_active"`
2091
    PreferredLanguage *string `json:"preferred_language"`
2092
    Timezone          *string `json:"timezone"`
2093
    UpdatedAt         *string `json:"updated_at,omitempty"`
2094

2095
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
2096
    Username *string `json:"username,omitempty"`
2097

2098
    // WordOfDayEmailEnabled Whether the user has enabled Word of the Day emails
2099
    WordOfDayEmailEnabled *bool `json:"word_of_day_email_enabled,omitempty"`
2100
}
2101

2102
// UserProfileMessageResponse defines model for UserProfileMessageResponse.
2103
type UserProfileMessageResponse struct {
2104
    Message string      `json:"message"`
2105
    User    UserProfile `json:"user"`
2106
}
2107

2108
// UserProgress defines model for UserProgress.
2109
type UserProgress struct {
2110
    AccuracyRate   *float32 `json:"accuracy_rate,omitempty"`
2111
    CorrectAnswers *int     `json:"correct_answers,omitempty"`
2112

2113
    // CurrentLevel Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
2114
    CurrentLevel *Level `json:"current_level,omitempty"`
2115

2116
    // GapAnalysis Analysis of learning gaps and areas needing attention
2117
    GapAnalysis     *map[string]interface{} `json:"gap_analysis,omitempty"`
2118
    GenerationFocus *GenerationFocus        `json:"generation_focus,omitempty"`
2119

2120
    // HighPriorityTopics Topics that have high priority scores for the user
2121
    HighPriorityTopics  *[]string                      `json:"high_priority_topics,omitempty"`
2122
    LearningPreferences *UserLearningPreferences       `json:"learning_preferences,omitempty"`
2123
    PerformanceByTopic  *map[string]PerformanceMetrics `json:"performance_by_topic,omitempty"`
2124

2125
    // PriorityDistribution Distribution of question priorities (high, medium, low counts)
2126
    PriorityDistribution *map[string]int   `json:"priority_distribution,omitempty"`
2127
    PriorityInsights     *PriorityInsights `json:"priority_insights,omitempty"`
2128
    RecentActivity       *[]UserResponse   `json:"recent_activity,omitempty"`
2129

2130
    // SuggestedLevel Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
2131
    SuggestedLevel *Level        `json:"suggested_level,omitempty"`
2132
    TotalQuestions *int          `json:"total_questions,omitempty"`
2133
    WeakAreas      *[]string     `json:"weak_areas,omitempty"`
2134
    WorkerStatus   *WorkerStatus `json:"worker_status,omitempty"`
2135
}
2136

2137
// UserQuestionStats defines model for UserQuestionStats.
2138
type UserQuestionStats struct {
2139
    AccuracyByLevel  *map[string]float32 `json:"accuracy_by_level,omitempty"`
2140
    AccuracyByType   *map[string]float32 `json:"accuracy_by_type,omitempty"`
2141
    AnsweredByLevel  *map[string]int     `json:"answered_by_level,omitempty"`
2142
    AnsweredByType   *map[string]int     `json:"answered_by_type,omitempty"`
2143
    AvailableByLevel *map[string]int     `json:"available_by_level,omitempty"`
2144
    AvailableByType  *map[string]int     `json:"available_by_type,omitempty"`
2145
    TotalAnswered    *int                `json:"total_answered,omitempty"`
2146
    UserId           *int64              `json:"user_id,omitempty"`
2147
}
2148

2149
// UserResponse defines model for UserResponse.
2150
type UserResponse struct {
2151
    CreatedAt  *string `json:"created_at,omitempty"`
2152
    IsCorrect  *bool   `json:"is_correct,omitempty"`
2153
    QuestionId *int64  `json:"question_id,omitempty"`
2154
}
2155

2156
// UserSettings defines model for UserSettings.
2157
type UserSettings struct {
2158
    // AiEnabled Whether AI features are enabled for this user
2159
    AiEnabled  *bool   `json:"ai_enabled,omitempty"`
2160
    AiModel    *string `json:"ai_model,omitempty"`
2161
    AiProvider *string `json:"ai_provider,omitempty"`
2162

2163
    // ApiKey API key for AI provider (write-only)
2164
    ApiKey *string `json:"api_key,omitempty"`
2165

2166
    // Language Learning language (dynamic). Allowed values come from config.yaml language_levels keys.
2167
    Language *Language `json:"language,omitempty"`
2168

2169
    // Level Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
2170
    Level *Level `json:"level,omitempty"`
2171
    union json.RawMessage
2172
}
2173

2174
// UserSettings0 defines model for .
2175
type UserSettings0 = interface{}
2176

2177
// UserSettings1 defines model for .
2178
type UserSettings1 = interface{}
2179

2180
// UserUpdateRequest defines model for UserUpdateRequest.
2181
type UserUpdateRequest struct {
2182
    // AiEnabled Whether AI features are enabled for this user
2183
    AiEnabled *bool `json:"ai_enabled,omitempty"`
2184

2185
    // AiModel AI model code
2186
    AiModel *string `json:"ai_model,omitempty"`
2187

2188
    // AiProvider AI provider code
2189
    AiProvider *string `json:"ai_provider,omitempty"`
2190

2191
    // ApiKey API key for AI provider (write-only)
2192
    ApiKey *string `json:"api_key,omitempty"`
2193

2194
    // CurrentLevel Current proficiency level
2195
    CurrentLevel *string `json:"current_level,omitempty"`
2196

2197
    // Email Email address
2198
    Email *openapi_types.Email `json:"email,omitempty"`
2199

2200
    // PreferredLanguage Preferred learning language
2201
    PreferredLanguage *string `json:"preferred_language,omitempty"`
2202

2203
    // SelectedRoles Array of role names to assign to the user
2204
    SelectedRoles *[]string `json:"selectedRoles,omitempty"`
2205

2206
    // Timezone Timezone (e.g., "UTC", "America/New_York")
2207
    Timezone *string `json:"timezone,omitempty"`
2208

2209
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
2210
    Username *string `json:"username,omitempty"`
2211
    union    json.RawMessage
2212
}
2213

2214
// UserUpdateRequest0 defines model for .
2215
type UserUpdateRequest0 = interface{}
2216

2217
// UserUpdateRequest1 defines model for .
2218
type UserUpdateRequest1 = interface{}
2219

2220
// UserUsageStats defines model for UserUsageStats.
2221
type UserUsageStats struct {
2222
    ApiKeyId         *int64              `json:"api_key_id,omitempty"`
2223
    CompletionTokens *int                `json:"completion_tokens,omitempty"`
2224
    CreatedAt        *string             `json:"created_at,omitempty"`
2225
    Id               *int64              `json:"id,omitempty"`
2226
    Model            *string             `json:"model,omitempty"`
2227
    PromptTokens     *int                `json:"prompt_tokens,omitempty"`
2228
    Provider         *string             `json:"provider,omitempty"`
2229
    RequestsMade     *int                `json:"requests_made,omitempty"`
2230
    ServiceName      *string             `json:"service_name,omitempty"`
2231
    TotalTokens      *int                `json:"total_tokens,omitempty"`
2232
    UpdatedAt        *string             `json:"updated_at,omitempty"`
2233
    UsageDate        *openapi_types.Date `json:"usage_date,omitempty"`
2234
    UsageHour        *int                `json:"usage_hour,omitempty"`
2235
    UsageType        *string             `json:"usage_type,omitempty"`
2236
    UserId           *int64              `json:"user_id,omitempty"`
2237
}
2238

2239
// UserUsageStatsDaily defines model for UserUsageStatsDaily.
2240
type UserUsageStatsDaily struct {
2241
    Model                 *string             `json:"model,omitempty"`
2242
    Provider              *string             `json:"provider,omitempty"`
2243
    ServiceName           *string             `json:"service_name,omitempty"`
2244
    TotalCompletionTokens *int                `json:"total_completion_tokens,omitempty"`
2245
    TotalPromptTokens     *int                `json:"total_prompt_tokens,omitempty"`
2246
    TotalRequests         *int                `json:"total_requests,omitempty"`
2247
    TotalTokens           *int                `json:"total_tokens,omitempty"`
2248
    UsageDate             *openapi_types.Date `json:"usage_date,omitempty"`
2249
    UsageType             *string             `json:"usage_type,omitempty"`
2250
}
2251

2252
// UserUsageStatsHourly defines model for UserUsageStatsHourly.
2253
type UserUsageStatsHourly struct {
2254
    Model                 *string `json:"model,omitempty"`
2255
    Provider              *string `json:"provider,omitempty"`
2256
    ServiceName           *string `json:"service_name,omitempty"`
2257
    TotalCompletionTokens *int    `json:"total_completion_tokens,omitempty"`
2258
    TotalPromptTokens     *int    `json:"total_prompt_tokens,omitempty"`
2259
    TotalRequests         *int    `json:"total_requests,omitempty"`
2260
    TotalTokens           *int    `json:"total_tokens,omitempty"`
2261
    UsageHour             *int    `json:"usage_hour,omitempty"`
2262
    UsageType             *string `json:"usage_type,omitempty"`
2263
}
2264

2265
// WordOfDayEmailPreferenceRequest defines model for WordOfDayEmailPreferenceRequest.
2266
type WordOfDayEmailPreferenceRequest struct {
2267
    // Enabled Whether to enable Word of the Day emails
2268
    Enabled bool `json:"enabled"`
2269
}
2270

2271
// WordOfDayHistoryResponse defines model for WordOfDayHistoryResponse.
2272
type WordOfDayHistoryResponse struct {
2273
    // Count Number of words returned
2274
    Count int                   `json:"count"`
2275
    Words []WordOfTheDayDisplay `json:"words"`
2276
}
2277

2278
// WordOfTheDayDisplay defines model for WordOfTheDayDisplay.
2279
type WordOfTheDayDisplay struct {
2280
    // Context Additional context for the word (primarily for snippets)
2281
    Context *string `json:"context"`
2282

2283
    // Date Date for the word of the day (YYYY-MM-DD)
2284
    Date openapi_types.Date `json:"date"`
2285

2286
    // Explanation Explanation of the word meaning or usage
2287
    Explanation *string `json:"explanation"`
2288

2289
    // Language Source language of the word
2290
    Language string `json:"language"`
2291

2292
    // Level CEFR difficulty level
2293
    Level *string `json:"level"`
2294

2295
    // Sentence Example sentence using the word in context
2296
    Sentence string `json:"sentence"`
2297

2298
    // SourceId ID of the source (question ID or snippet ID)
2299
    SourceId int64 `json:"source_id"`
2300

2301
    // SourceType Source type of the word (from vocabulary question or user snippet)
2302
    SourceType WordOfTheDayDisplaySourceType `json:"source_type"`
2303

2304
    // TopicCategory Topic category for the word
2305
    TopicCategory *string `json:"topic_category"`
2306

2307
    // Translation English translation of the word
2308
    Translation string `json:"translation"`
2309

2310
    // Word The word or phrase being featured
2311
    Word string `json:"word"`
2312
}
2313

2314
// WordOfTheDayDisplaySourceType Source type of the word (from vocabulary question or user snippet)
2315
type WordOfTheDayDisplaySourceType string
2316

2317
// WorkerAIConcurrencyStats defines model for WorkerAIConcurrencyStats.
2318
type WorkerAIConcurrencyStats struct {
2319
    ActiveRequests *int `json:"active_requests,omitempty"`
2320
    MaxConcurrent  *int `json:"max_concurrent,omitempty"`
2321
    QueuedRequests *int `json:"queued_requests,omitempty"`
2322
    TotalRequests  *int `json:"total_requests,omitempty"`
2323
}
2324

2325
// WorkerDetailsResponse defines model for WorkerDetailsResponse.
2326
type WorkerDetailsResponse map[string]interface{}
2327

2328
// WorkerHealth defines model for WorkerHealth.
2329
type WorkerHealth struct {
2330
    GlobalPaused    *bool `json:"global_paused,omitempty"`
2331
    HealthyCount    *int  `json:"healthy_count,omitempty"`
2332
    TotalCount      *int  `json:"total_count,omitempty"`
2333
    WorkerInstances *[]struct {
2334
        Healthy       *bool `json:"healthy,omitempty"`
2335
        IsPaused      *bool `json:"is_paused,omitempty"`
2336
        IsRunning     *bool `json:"is_running,omitempty"`
2337
        LastHeartbeat *struct {
2338
            Time  *string `json:"Time,omitempty"`
2339
            Valid *bool   `json:"Valid,omitempty"`
2340
        } `json:"last_heartbeat,omitempty"`
2341
        TotalQuestionsGenerated *int    `json:"total_questions_generated,omitempty"`
2342
        TotalRuns               *int    `json:"total_runs,omitempty"`
2343
        WorkerInstance          *string `json:"worker_instance,omitempty"`
2344
    } `json:"worker_instances,omitempty"`
2345
}
2346

2347
// WorkerLogsResponse defines model for WorkerLogsResponse.
2348
type WorkerLogsResponse struct {
2349
    Logs []map[string]interface{} `json:"logs"`
2350
}
2351

2352
// WorkerNotificationErrorsResponse defines model for WorkerNotificationErrorsResponse.
2353
type WorkerNotificationErrorsResponse struct {
2354
    Errors     []NotificationError    `json:"errors"`
2355
    Pagination PaginationInfo         `json:"pagination"`
2356
    Stats      NotificationErrorStats `json:"stats"`
2357
}
2358

2359
// WorkerNotificationSentResponse defines model for WorkerNotificationSentResponse.
2360
type WorkerNotificationSentResponse struct {
2361
    Notifications []SentNotification `json:"notifications"`
2362
    Pagination    PaginationInfo     `json:"pagination"`
2363
    Stats         NotificationStats  `json:"stats"`
2364
}
2365

2366
// WorkerStatus defines model for WorkerStatus.
2367
type WorkerStatus struct {
2368
    // ErrorMessage Error message if the worker is in an error state
2369
    ErrorMessage *string `json:"error_message"`
2370

2371
    // LastHeartbeat Timestamp of the last heartbeat from the worker
2372
    LastHeartbeat *string `json:"last_heartbeat,omitempty"`
2373

2374
    // Status Current status of the worker
2375
    Status *WorkerStatusStatus `json:"status,omitempty"`
2376
}
2377

2378
// WorkerStatusStatus Current status of the worker
2379
type WorkerStatusStatus string
2380

2381
// WorkerStatusResponse defines model for WorkerStatusResponse.
2382
type WorkerStatusResponse struct {
2383
    // ErrorMessage Error message if worker has errors
2384
    ErrorMessage string `json:"error_message"`
2385

2386
    // GlobalPaused Whether the worker is globally paused
2387
    GlobalPaused bool `json:"global_paused"`
2388

2389
    // HasErrors Whether the worker has encountered errors
2390
    HasErrors bool `json:"has_errors"`
2391

2392
    // HealthyWorkers Number of healthy worker instances
2393
    HealthyWorkers int `json:"healthy_workers"`
2394

2395
    // LastErrorDetails Detailed error information if any
2396
    LastErrorDetails string `json:"last_error_details"`
2397

2398
    // TotalWorkers Total number of worker instances
2399
    TotalWorkers int `json:"total_workers"`
2400

2401
    // UserPaused Whether the user's question generation is paused
2402
    UserPaused bool `json:"user_paused"`
2403

2404
    // WorkerRunning Whether the worker is currently running
2405
    WorkerRunning bool `json:"worker_running"`
2406
}
2407

2408
// WorkerUserListResponse defines model for WorkerUserListResponse.
2409
type WorkerUserListResponse struct {
2410
    Users []WorkerUserStatus `json:"users"`
2411
}
2412

2413
// WorkerUserStatus defines model for WorkerUserStatus.
2414
type WorkerUserStatus struct {
2415
    Id       int    `json:"id"`
2416
    IsPaused bool   `json:"is_paused"`
2417
    Username string `json:"username"`
2418
}
2419

2420
// DeleteV1AdminBackendFeedbackParams defines parameters for DeleteV1AdminBackendFeedback.
2421
type DeleteV1AdminBackendFeedbackParams struct {
2422
    // Status Status of feedback reports to delete
2423
    Status DeleteV1AdminBackendFeedbackParamsStatus `form:"status" json:"status"`
2424
}
2425

2426
// DeleteV1AdminBackendFeedbackParamsStatus defines parameters for DeleteV1AdminBackendFeedback.
2427
type DeleteV1AdminBackendFeedbackParamsStatus string
2428

2429
// GetV1AdminBackendFeedbackParams defines parameters for GetV1AdminBackendFeedback.
2430
type GetV1AdminBackendFeedbackParams struct {
2431
    // Page Page number
2432
    Page *int `form:"page,omitempty" json:"page,omitempty"`
2433

2434
    // PageSize Number of items per page
2435
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
2436

2437
    // Status Filter by status
2438
    Status *GetV1AdminBackendFeedbackParamsStatus `form:"status,omitempty" json:"status,omitempty"`
2439

2440
    // FeedbackType Filter by feedback type
2441
    FeedbackType *string `form:"feedback_type,omitempty" json:"feedback_type,omitempty"`
2442

2443
    // UserId Filter by user ID
2444
    UserId *int `form:"user_id,omitempty" json:"user_id,omitempty"`
2445
}
2446

2447
// GetV1AdminBackendFeedbackParamsStatus defines parameters for GetV1AdminBackendFeedback.
2448
type GetV1AdminBackendFeedbackParamsStatus string
2449

2450
// GetV1AdminBackendQuestionsParams defines parameters for GetV1AdminBackendQuestions.
2451
type GetV1AdminBackendQuestionsParams struct {
2452
    // Page Page number (1-based)
2453
    Page *int `form:"page,omitempty" json:"page,omitempty"`
2454

2455
    // PageSize Number of questions per page
2456
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
2457

2458
    // Search Search term for question content
2459
    Search *string `form:"search,omitempty" json:"search,omitempty"`
2460

2461
    // Type Filter by question type
2462
    Type *QuestionType `form:"type,omitempty" json:"type,omitempty"`
2463

2464
    // Status Filter by question status
2465
    Status *QuestionStatus `form:"status,omitempty" json:"status,omitempty"`
2466

2467
    // Language Filter by language
2468
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
2469

2470
    // Level Filter by level
2471
    Level *Level `form:"level,omitempty" json:"level,omitempty"`
2472

2473
    // UserId Filter by user ID (optional)
2474
    UserId *int64 `form:"user_id,omitempty" json:"user_id,omitempty"`
2475
}
2476

2477
// GetV1AdminBackendQuestionsPaginatedParams defines parameters for GetV1AdminBackendQuestionsPaginated.
2478
type GetV1AdminBackendQuestionsPaginatedParams struct {
2479
    // Page Page number (1-based)
2480
    Page *int `form:"page,omitempty" json:"page,omitempty"`
2481

2482
    // PageSize Number of questions per page
2483
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
2484

2485
    // Search Search term for question content
2486
    Search *string `form:"search,omitempty" json:"search,omitempty"`
2487

2488
    // Type Filter by question type
2489
    Type *QuestionType `form:"type,omitempty" json:"type,omitempty"`
2490

2491
    // Status Filter by question status
2492
    Status *QuestionStatus `form:"status,omitempty" json:"status,omitempty"`
2493

2494
    // Language Filter by language
2495
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
2496

2497
    // Level Filter by level
2498
    Level *Level `form:"level,omitempty" json:"level,omitempty"`
2499

2500
    // UserId Filter by user ID (optional)
2501
    UserId *int64 `form:"user_id,omitempty" json:"user_id,omitempty"`
2502
}
2503

2504
// PutV1AdminBackendQuestionsIdJSONBody defines parameters for PutV1AdminBackendQuestionsId.
2505
type PutV1AdminBackendQuestionsIdJSONBody struct {
2506
    // Content Updated question content
2507
    Content map[string]interface{} `json:"content"`
2508

2509
    // CorrectAnswer Index of the correct answer
2510
    CorrectAnswer *int `json:"correct_answer,omitempty"`
2511

2512
    // Explanation Explanation for the correct answer
2513
    Explanation string `json:"explanation"`
2514
}
2515

2516
// PostV1AdminBackendQuestionsIdAiFixJSONBody defines parameters for PostV1AdminBackendQuestionsIdAiFix.
2517
type PostV1AdminBackendQuestionsIdAiFixJSONBody struct {
2518
    AdditionalContext *string `json:"additional_context,omitempty"`
2519
}
2520

2521
// PostV1AdminBackendQuestionsIdAssignUsersJSONBody defines parameters for PostV1AdminBackendQuestionsIdAssignUsers.
2522
type PostV1AdminBackendQuestionsIdAssignUsersJSONBody struct {
2523
    // UserIds Array of user IDs to assign to the question
2524
    UserIds []int64 `json:"user_ids"`
2525
}
2526

2527
// PostV1AdminBackendQuestionsIdUnassignUsersJSONBody defines parameters for PostV1AdminBackendQuestionsIdUnassignUsers.
2528
type PostV1AdminBackendQuestionsIdUnassignUsersJSONBody struct {
2529
    // UserIds Array of user IDs to unassign from the question
2530
    UserIds []int64 `json:"user_ids"`
2531
}
2532

2533
// GetV1AdminBackendReportedQuestionsParams defines parameters for GetV1AdminBackendReportedQuestions.
2534
type GetV1AdminBackendReportedQuestionsParams struct {
2535
    // Page Page number (1-based)
2536
    Page *int `form:"page,omitempty" json:"page,omitempty"`
2537

2538
    // PageSize Number of questions per page
2539
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
2540

2541
    // Search Search term for question content
2542
    Search *string `form:"search,omitempty" json:"search,omitempty"`
2543

2544
    // Type Filter by question type
2545
    Type *QuestionType `form:"type,omitempty" json:"type,omitempty"`
2546

2547
    // Language Filter by language
2548
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
2549

2550
    // Level Filter by level
2551
    Level *Level `form:"level,omitempty" json:"level,omitempty"`
2552
}
2553

2554
// GetV1AdminBackendStoriesParams defines parameters for GetV1AdminBackendStories.
2555
type GetV1AdminBackendStoriesParams struct {
2556
    // Page Page number (1-based)
2557
    Page *int `form:"page,omitempty" json:"page,omitempty"`
2558

2559
    // PageSize Number of stories per page
2560
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
2561

2562
    // Search Search term for story title
2563
    Search *string `form:"search,omitempty" json:"search,omitempty"`
2564

2565
    // Language Filter by language
2566
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
2567

2568
    // Status Filter by story status
2569
    Status *StoryStatus `form:"status,omitempty" json:"status,omitempty"`
2570

2571
    // UserId Filter by user ID (optional)
2572
    UserId *int64 `form:"user_id,omitempty" json:"user_id,omitempty"`
2573
}
2574

2575
// PostV1AdminBackendUserzJSONBody defines parameters for PostV1AdminBackendUserz.
2576
type PostV1AdminBackendUserzJSONBody struct {
2577
    // AiEnabled Whether AI is enabled for this user
2578
    AiEnabled *bool `json:"ai_enabled,omitempty"`
2579

2580
    // AiModel AI model preference
2581
    AiModel *string `json:"ai_model,omitempty"`
2582

2583
    // AiProvider AI provider preference
2584
    AiProvider *string `json:"ai_provider,omitempty"`
2585

2586
    // Email Email address for the new user
2587
    Email openapi_types.Email `json:"email"`
2588

2589
    // Language Preferred language for the user
2590
    Language *string `json:"language,omitempty"`
2591

2592
    // Level Current level for the user
2593
    Level *string `json:"level,omitempty"`
2594

2595
    // Password Password for the new user
2596
    Password string `json:"password"`
2597

2598
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
2599
    Username string `json:"username"`
2600
}
2601

2602
// GetV1AdminBackendUserzPaginatedParams defines parameters for GetV1AdminBackendUserzPaginated.
2603
type GetV1AdminBackendUserzPaginatedParams struct {
2604
    // Page Page number (1-based)
2605
    Page *int `form:"page,omitempty" json:"page,omitempty"`
2606

2607
    // PageSize Number of users per page
2608
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
2609

2610
    // Search Search term for username or email
2611
    Search *string `form:"search,omitempty" json:"search,omitempty"`
2612

2613
    // Language Filter by preferred language
2614
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
2615

2616
    // Level Filter by current level
2617
    Level *Level `form:"level,omitempty" json:"level,omitempty"`
2618

2619
    // AiProvider Filter by AI provider
2620
    AiProvider *string `form:"ai_provider,omitempty" json:"ai_provider,omitempty"`
2621

2622
    // AiModel Filter by AI model
2623
    AiModel *string `form:"ai_model,omitempty" json:"ai_model,omitempty"`
2624

2625
    // AiEnabled Filter by AI enabled status
2626
    AiEnabled *GetV1AdminBackendUserzPaginatedParamsAiEnabled `form:"ai_enabled,omitempty" json:"ai_enabled,omitempty"`
2627

2628
    // Active Filter by active status (active within 7 days)
2629
    Active *GetV1AdminBackendUserzPaginatedParamsActive `form:"active,omitempty" json:"active,omitempty"`
2630
}
2631

2632
// GetV1AdminBackendUserzPaginatedParamsAiEnabled defines parameters for GetV1AdminBackendUserzPaginated.
2633
type GetV1AdminBackendUserzPaginatedParamsAiEnabled string
2634

2635
// GetV1AdminBackendUserzPaginatedParamsActive defines parameters for GetV1AdminBackendUserzPaginated.
2636
type GetV1AdminBackendUserzPaginatedParamsActive string
2637

2638
// PostV1AdminBackendUserzIdRolesJSONBody defines parameters for PostV1AdminBackendUserzIdRoles.
2639
type PostV1AdminBackendUserzIdRolesJSONBody struct {
2640
    // RoleId Role ID to assign
2641
    RoleId int64 `json:"role_id"`
2642
}
2643

2644
// GetV1AdminWorkerNotificationsErrorsParams defines parameters for GetV1AdminWorkerNotificationsErrors.
2645
type GetV1AdminWorkerNotificationsErrorsParams struct {
2646
    // Page Page number (1-based)
2647
    Page *int `form:"page,omitempty" json:"page,omitempty"`
2648

2649
    // PageSize Number of errors per page
2650
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
2651

2652
    // ErrorType Filter by error type
2653
    ErrorType *GetV1AdminWorkerNotificationsErrorsParamsErrorType `form:"error_type,omitempty" json:"error_type,omitempty"`
2654

2655
    // NotificationType Filter by notification type
2656
    NotificationType *GetV1AdminWorkerNotificationsErrorsParamsNotificationType `form:"notification_type,omitempty" json:"notification_type,omitempty"`
2657

2658
    // Resolved Filter by resolution status
2659
    Resolved *GetV1AdminWorkerNotificationsErrorsParamsResolved `form:"resolved,omitempty" json:"resolved,omitempty"`
2660
}
2661

2662
// GetV1AdminWorkerNotificationsErrorsParamsErrorType defines parameters for GetV1AdminWorkerNotificationsErrors.
2663
type GetV1AdminWorkerNotificationsErrorsParamsErrorType string
2664

2665
// GetV1AdminWorkerNotificationsErrorsParamsNotificationType defines parameters for GetV1AdminWorkerNotificationsErrors.
2666
type GetV1AdminWorkerNotificationsErrorsParamsNotificationType string
2667

2668
// GetV1AdminWorkerNotificationsErrorsParamsResolved defines parameters for GetV1AdminWorkerNotificationsErrors.
2669
type GetV1AdminWorkerNotificationsErrorsParamsResolved string
2670

2671
// PostV1AdminWorkerNotificationsForceSendJSONBody defines parameters for PostV1AdminWorkerNotificationsForceSend.
2672
type PostV1AdminWorkerNotificationsForceSendJSONBody struct {
2673
    // Username Username of the user to send notification to
2674
    Username string `json:"username"`
2675
}
2676

2677
// GetV1AdminWorkerNotificationsSentParams defines parameters for GetV1AdminWorkerNotificationsSent.
2678
type GetV1AdminWorkerNotificationsSentParams struct {
2679
    // Page Page number (1-based)
2680
    Page *int `form:"page,omitempty" json:"page,omitempty"`
2681

2682
    // PageSize Number of notifications per page
2683
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
2684

2685
    // NotificationType Filter by notification type
2686
    NotificationType *GetV1AdminWorkerNotificationsSentParamsNotificationType `form:"notification_type,omitempty" json:"notification_type,omitempty"`
2687

2688
    // Status Filter by status
2689
    Status *GetV1AdminWorkerNotificationsSentParamsStatus `form:"status,omitempty" json:"status,omitempty"`
2690

2691
    // SentAfter Filter notifications sent after this timestamp
2692
    SentAfter *string `form:"sent_after,omitempty" json:"sent_after,omitempty"`
2693

2694
    // SentBefore Filter notifications sent before this timestamp
2695
    SentBefore *string `form:"sent_before,omitempty" json:"sent_before,omitempty"`
2696
}
2697

2698
// GetV1AdminWorkerNotificationsSentParamsNotificationType defines parameters for GetV1AdminWorkerNotificationsSent.
2699
type GetV1AdminWorkerNotificationsSentParamsNotificationType string
2700

2701
// GetV1AdminWorkerNotificationsSentParamsStatus defines parameters for GetV1AdminWorkerNotificationsSent.
2702
type GetV1AdminWorkerNotificationsSentParamsStatus string
2703

2704
// GetV1AiBookmarksParams defines parameters for GetV1AiBookmarks.
2705
type GetV1AiBookmarksParams struct {
2706
    // Q Optional search query to filter bookmarked messages
2707
    Q *string `form:"q,omitempty" json:"q,omitempty"`
2708

2709
    // Limit Maximum number of messages to return
2710
    Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
2711

2712
    // Offset Number of messages to skip
2713
    Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
2714
}
2715

2716
// GetV1AiConversationsParams defines parameters for GetV1AiConversations.
2717
type GetV1AiConversationsParams struct {
2718
    // Limit Maximum number of conversations to return
2719
    Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
2720

2721
    // Offset Number of conversations to skip
2722
    Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
2723
}
2724

2725
// PutV1AiConversationsBookmarkJSONBody defines parameters for PutV1AiConversationsBookmark.
2726
type PutV1AiConversationsBookmarkJSONBody struct {
2727
    // ConversationId ID of the conversation containing the message
2728
    ConversationId openapi_types.UUID `json:"conversation_id"`
2729

2730
    // MessageId ID of the message to bookmark/unbookmark
2731
    MessageId openapi_types.UUID `json:"message_id"`
2732
}
2733

2734
// GetV1AiSearchParams defines parameters for GetV1AiSearch.
2735
type GetV1AiSearchParams struct {
2736
    // Q Search query string
2737
    Q string `form:"q" json:"q"`
2738

2739
    // Limit Maximum number of results to return
2740
    Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
2741

2742
    // Offset Number of results to skip
2743
    Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
2744
}
2745

2746
// GetV1AuthGoogleCallbackParams defines parameters for GetV1AuthGoogleCallback.
2747
type GetV1AuthGoogleCallbackParams struct {
2748
    // Code Authorization code from Google
2749
    Code string `form:"code" json:"code"`
2750

2751
    // State State parameter for CSRF protection
2752
    State *string `form:"state,omitempty" json:"state,omitempty"`
2753
}
2754

2755
// PostV1DailyQuestionsDateAnswerQuestionIdJSONBody defines parameters for PostV1DailyQuestionsDateAnswerQuestionId.
2756
type PostV1DailyQuestionsDateAnswerQuestionIdJSONBody struct {
2757
    // UserAnswerIndex Index of the user's selected answer (0-based)
2758
    UserAnswerIndex int `json:"user_answer_index"`
2759
}
2760

2761
// GetV1QuizAiTokenUsageParams defines parameters for GetV1QuizAiTokenUsage.
2762
type GetV1QuizAiTokenUsageParams struct {
2763
    // StartDate Start date in YYYY-MM-DD format
2764
    StartDate openapi_types.Date `form:"startDate" json:"startDate"`
2765

2766
    // EndDate End date in YYYY-MM-DD format
2767
    EndDate openapi_types.Date `form:"endDate" json:"endDate"`
2768
}
2769

2770
// GetV1QuizAiTokenUsageDailyParams defines parameters for GetV1QuizAiTokenUsageDaily.
2771
type GetV1QuizAiTokenUsageDailyParams struct {
2772
    // StartDate Start date in YYYY-MM-DD format
2773
    StartDate openapi_types.Date `form:"startDate" json:"startDate"`
2774

2775
    // EndDate End date in YYYY-MM-DD format
2776
    EndDate openapi_types.Date `form:"endDate" json:"endDate"`
2777
}
2778

2779
// GetV1QuizAiTokenUsageHourlyParams defines parameters for GetV1QuizAiTokenUsageHourly.
2780
type GetV1QuizAiTokenUsageHourlyParams struct {
2781
    // Date Date in YYYY-MM-DD format
2782
    Date openapi_types.Date `form:"date" json:"date"`
2783
}
2784

2785
// GetV1QuizQuestionParams defines parameters for GetV1QuizQuestion.
2786
type GetV1QuizQuestionParams struct {
2787
    // Language Preferred language for the question
2788
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
2789

2790
    // Level Difficulty level for the question
2791
    Level *Level `form:"level,omitempty" json:"level,omitempty"`
2792

2793
    // Type Specific question type(s) to retrieve (comma-separated list). If multiple types are provided, the first valid type will be used.
2794
    Type *string `form:"type,omitempty" json:"type,omitempty"`
2795

2796
    // ExcludeType Question type(s) to exclude from random selection (comma-separated list). Useful for filtering out specific question types from the general quiz.
2797
    ExcludeType *string `form:"exclude_type,omitempty" json:"exclude_type,omitempty"`
2798
}
2799

2800
// GetV1SettingsLevelsParams defines parameters for GetV1SettingsLevels.
2801
type GetV1SettingsLevelsParams struct {
2802
    // Language Language to get levels for (optional - returns all levels if not specified)
2803
    Language *string `form:"language,omitempty" json:"language,omitempty"`
2804
}
2805

2806
// GetV1SnippetsParams defines parameters for GetV1Snippets.
2807
type GetV1SnippetsParams struct {
2808
    // Q Optional search query to filter snippets by text content
2809
    Q *string `form:"q,omitempty" json:"q,omitempty"`
2810

2811
    // SourceLang Filter by source language
2812
    SourceLang *string `form:"source_lang,omitempty" json:"source_lang,omitempty"`
2813

2814
    // TargetLang Filter by target language
2815
    TargetLang *string `form:"target_lang,omitempty" json:"target_lang,omitempty"`
2816

2817
    // StoryId Filter by story ID
2818
    StoryId *int64 `form:"story_id,omitempty" json:"story_id,omitempty"`
2819

2820
    // Level Filter by difficulty level (CEFR level)
2821
    Level *GetV1SnippetsParamsLevel `form:"level,omitempty" json:"level,omitempty"`
2822

2823
    // Limit Maximum number of snippets to return (default 50, max 100)
2824
    Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
2825

2826
    // Offset Number of snippets to skip for pagination
2827
    Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
2828
}
2829

2830
// GetV1SnippetsParamsLevel defines parameters for GetV1Snippets.
2831
type GetV1SnippetsParamsLevel string
2832

2833
// GetV1SnippetsSearchParams defines parameters for GetV1SnippetsSearch.
2834
type GetV1SnippetsSearchParams struct {
2835
    // Q Search query string
2836
    Q string `form:"q" json:"q"`
2837

2838
    // SourceLang Filter results by source language
2839
    SourceLang *string `form:"source_lang,omitempty" json:"source_lang,omitempty"`
2840

2841
    // Limit Maximum number of results to return
2842
    Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
2843

2844
    // Offset Number of results to skip
2845
    Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
2846
}
2847

2848
// GetV1StoryParams defines parameters for GetV1Story.
2849
type GetV1StoryParams struct {
2850
    // IncludeArchived Include archived stories in the response
2851
    IncludeArchived *bool `form:"include_archived,omitempty" json:"include_archived,omitempty"`
2852
}
2853

2854
// GetV1TranslationPracticeHistoryParams defines parameters for GetV1TranslationPracticeHistory.
2855
type GetV1TranslationPracticeHistoryParams struct {
2856
    // Limit Maximum number of sessions to return
2857
    Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
2858

2859
    // Search Search query to filter sessions by original sentence, user translation, feedback, or direction
2860
    Search *string `form:"search,omitempty" json:"search,omitempty"`
2861

2862
    // Offset Number of sessions to skip for pagination
2863
    Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
2864
}
2865

2866
// GetV1TranslationPracticeSentenceParams defines parameters for GetV1TranslationPracticeSentence.
2867
type GetV1TranslationPracticeSentenceParams struct {
2868
    // Language Learning language code
2869
    Language string `form:"language" json:"language"`
2870

2871
    // Level Language level (e.g., A1, A2, B1, B2)
2872
    Level string `form:"level" json:"level"`
2873

2874
    // Direction Translation direction
2875
    Direction GetV1TranslationPracticeSentenceParamsDirection `form:"direction" json:"direction"`
2876
}
2877

2878
// GetV1TranslationPracticeSentenceParamsDirection defines parameters for GetV1TranslationPracticeSentence.
2879
type GetV1TranslationPracticeSentenceParamsDirection string
2880

2881
// GetV1WordOfDayEmbedParams defines parameters for GetV1WordOfDayEmbed.
2882
type GetV1WordOfDayEmbedParams struct {
2883
    // Date Optional date in YYYY-MM-DD format. Defaults to today's date in the user's timezone when omitted.
2884
    Date *openapi_types.Date `form:"date,omitempty" json:"date,omitempty"`
2885
}
2886

2887
// GetV1WordOfDayHistoryParams defines parameters for GetV1WordOfDayHistory.
2888
type GetV1WordOfDayHistoryParams struct {
2889
    // StartDate Start date in YYYY-MM-DD format
2890
    StartDate openapi_types.Date `form:"start_date" json:"start_date"`
2891

2892
    // EndDate End date in YYYY-MM-DD format
2893
    EndDate openapi_types.Date `form:"end_date" json:"end_date"`
2894
}
2895

2896
// PatchV1AdminBackendFeedbackIdJSONRequestBody defines body for PatchV1AdminBackendFeedbackId for application/json ContentType.
2897
type PatchV1AdminBackendFeedbackIdJSONRequestBody = FeedbackUpdateRequest
2898

2899
// PutV1AdminBackendQuestionsIdJSONRequestBody defines body for PutV1AdminBackendQuestionsId for application/json ContentType.
2900
type PutV1AdminBackendQuestionsIdJSONRequestBody PutV1AdminBackendQuestionsIdJSONBody
2901

2902
// PostV1AdminBackendQuestionsIdAiFixJSONRequestBody defines body for PostV1AdminBackendQuestionsIdAiFix for application/json ContentType.
2903
type PostV1AdminBackendQuestionsIdAiFixJSONRequestBody PostV1AdminBackendQuestionsIdAiFixJSONBody
2904

2905
// PostV1AdminBackendQuestionsIdAssignUsersJSONRequestBody defines body for PostV1AdminBackendQuestionsIdAssignUsers for application/json ContentType.
2906
type PostV1AdminBackendQuestionsIdAssignUsersJSONRequestBody PostV1AdminBackendQuestionsIdAssignUsersJSONBody
2907

2908
// PostV1AdminBackendQuestionsIdUnassignUsersJSONRequestBody defines body for PostV1AdminBackendQuestionsIdUnassignUsers for application/json ContentType.
2909
type PostV1AdminBackendQuestionsIdUnassignUsersJSONRequestBody PostV1AdminBackendQuestionsIdUnassignUsersJSONBody
2910

2911
// PostV1AdminBackendUserzJSONRequestBody defines body for PostV1AdminBackendUserz for application/json ContentType.
2912
type PostV1AdminBackendUserzJSONRequestBody PostV1AdminBackendUserzJSONBody
2913

2914
// PutV1AdminBackendUserzIdJSONRequestBody defines body for PutV1AdminBackendUserzId for application/json ContentType.
2915
type PutV1AdminBackendUserzIdJSONRequestBody = UserUpdateRequest
2916

2917
// PostV1AdminBackendUserzIdResetPasswordJSONRequestBody defines body for PostV1AdminBackendUserzIdResetPassword for application/json ContentType.
2918
type PostV1AdminBackendUserzIdResetPasswordJSONRequestBody = PasswordResetRequest
2919

2920
// PostV1AdminBackendUserzIdRolesJSONRequestBody defines body for PostV1AdminBackendUserzIdRoles for application/json ContentType.
2921
type PostV1AdminBackendUserzIdRolesJSONRequestBody PostV1AdminBackendUserzIdRolesJSONBody
2922

2923
// PostV1AdminWorkerNotificationsForceSendJSONRequestBody defines body for PostV1AdminWorkerNotificationsForceSend for application/json ContentType.
2924
type PostV1AdminWorkerNotificationsForceSendJSONRequestBody PostV1AdminWorkerNotificationsForceSendJSONBody
2925

2926
// PostV1AdminWorkerUsersPauseJSONRequestBody defines body for PostV1AdminWorkerUsersPause for application/json ContentType.
2927
type PostV1AdminWorkerUsersPauseJSONRequestBody = UserIdRequest
2928

2929
// PostV1AdminWorkerUsersResumeJSONRequestBody defines body for PostV1AdminWorkerUsersResume for application/json ContentType.
2930
type PostV1AdminWorkerUsersResumeJSONRequestBody = UserIdRequest
2931

2932
// PostV1AiConversationsJSONRequestBody defines body for PostV1AiConversations for application/json ContentType.
2933
type PostV1AiConversationsJSONRequestBody = CreateConversationRequest
2934

2935
// PutV1AiConversationsBookmarkJSONRequestBody defines body for PutV1AiConversationsBookmark for application/json ContentType.
2936
type PutV1AiConversationsBookmarkJSONRequestBody PutV1AiConversationsBookmarkJSONBody
2937

2938
// PostV1AiConversationsConversationIdMessagesJSONRequestBody defines body for PostV1AiConversationsConversationIdMessages for application/json ContentType.
2939
type PostV1AiConversationsConversationIdMessagesJSONRequestBody = CreateMessageRequest
2940

2941
// PutV1AiConversationsIdJSONRequestBody defines body for PutV1AiConversationsId for application/json ContentType.
2942
type PutV1AiConversationsIdJSONRequestBody = UpdateConversationRequest
2943

2944
// PostV1ApiKeysJSONRequestBody defines body for PostV1ApiKeys for application/json ContentType.
2945
type PostV1ApiKeysJSONRequestBody = CreateAPIKeyRequest
2946

2947
// PostV1AudioSpeechJSONRequestBody defines body for PostV1AudioSpeech for application/json ContentType.
2948
type PostV1AudioSpeechJSONRequestBody = TTSRequest
2949

2950
// PostV1AudioSpeechInitJSONRequestBody defines body for PostV1AudioSpeechInit for application/json ContentType.
2951
type PostV1AudioSpeechInitJSONRequestBody = TTSRequest
2952

2953
// PostV1AuthLoginJSONRequestBody defines body for PostV1AuthLogin for application/json ContentType.
2954
type PostV1AuthLoginJSONRequestBody = LoginRequest
2955

2956
// PostV1AuthSignupJSONRequestBody defines body for PostV1AuthSignup for application/json ContentType.
2957
type PostV1AuthSignupJSONRequestBody = UserCreateRequest
2958

2959
// PostV1DailyQuestionsDateAnswerQuestionIdJSONRequestBody defines body for PostV1DailyQuestionsDateAnswerQuestionId for application/json ContentType.
2960
type PostV1DailyQuestionsDateAnswerQuestionIdJSONRequestBody PostV1DailyQuestionsDateAnswerQuestionIdJSONBody
2961

2962
// PostV1FeedbackJSONRequestBody defines body for PostV1Feedback for application/json ContentType.
2963
type PostV1FeedbackJSONRequestBody = FeedbackSubmissionRequest
2964

2965
// PutV1PreferencesLearningJSONRequestBody defines body for PutV1PreferencesLearning for application/json ContentType.
2966
type PutV1PreferencesLearningJSONRequestBody = UserLearningPreferences
2967

2968
// PostV1QuizAnswerJSONRequestBody defines body for PostV1QuizAnswer for application/json ContentType.
2969
type PostV1QuizAnswerJSONRequestBody = AnswerRequest
2970

2971
// PostV1QuizChatStreamJSONRequestBody defines body for PostV1QuizChatStream for application/json ContentType.
2972
type PostV1QuizChatStreamJSONRequestBody = QuizChatRequest
2973

2974
// PostV1QuizQuestionIdMarkKnownJSONRequestBody defines body for PostV1QuizQuestionIdMarkKnown for application/json ContentType.
2975
type PostV1QuizQuestionIdMarkKnownJSONRequestBody = MarkQuestionKnownRequest
2976

2977
// PostV1QuizQuestionIdReportJSONRequestBody defines body for PostV1QuizQuestionIdReport for application/json ContentType.
2978
type PostV1QuizQuestionIdReportJSONRequestBody = ReportQuestionRequest
2979

2980
// PutV1SettingsJSONRequestBody defines body for PutV1Settings for application/json ContentType.
2981
type PutV1SettingsJSONRequestBody = UserSettings
2982

2983
// PostV1SettingsTestAiJSONRequestBody defines body for PostV1SettingsTestAi for application/json ContentType.
2984
type PostV1SettingsTestAiJSONRequestBody = TestAIRequest
2985

2986
// PutV1SettingsWordOfDayEmailJSONRequestBody defines body for PutV1SettingsWordOfDayEmail for application/json ContentType.
2987
type PutV1SettingsWordOfDayEmailJSONRequestBody = WordOfDayEmailPreferenceRequest
2988

2989
// PostV1SnippetsJSONRequestBody defines body for PostV1Snippets for application/json ContentType.
2990
type PostV1SnippetsJSONRequestBody = CreateSnippetRequest
2991

2992
// PutV1SnippetsIdJSONRequestBody defines body for PutV1SnippetsId for application/json ContentType.
2993
type PutV1SnippetsIdJSONRequestBody = UpdateSnippetRequest
2994

2995
// PostV1StoryJSONRequestBody defines body for PostV1Story for application/json ContentType.
2996
type PostV1StoryJSONRequestBody = CreateStoryRequest
2997

2998
// PostV1StoryIdGenerateJSONRequestBody defines body for PostV1StoryIdGenerate for application/json ContentType.
2999
type PostV1StoryIdGenerateJSONRequestBody = EmptyRequest
3000

3001
// PostV1StoryIdToggleAutoGenerationJSONRequestBody defines body for PostV1StoryIdToggleAutoGeneration for application/json ContentType.
3002
type PostV1StoryIdToggleAutoGenerationJSONRequestBody = ToggleAutoGenerationRequest
3003

3004
// PostV1TranslateJSONRequestBody defines body for PostV1Translate for application/json ContentType.
3005
type PostV1TranslateJSONRequestBody = TranslateRequest
3006

3007
// PostV1TranslationPracticeGenerateJSONRequestBody defines body for PostV1TranslationPracticeGenerate for application/json ContentType.
3008
type PostV1TranslationPracticeGenerateJSONRequestBody = TranslationPracticeGenerateRequest
3009

3010
// PostV1TranslationPracticeSubmitJSONRequestBody defines body for PostV1TranslationPracticeSubmit for application/json ContentType.
3011
type PostV1TranslationPracticeSubmitJSONRequestBody = TranslationPracticeSubmitRequest
3012

3013
// PutV1UserzProfileJSONRequestBody defines body for PutV1UserzProfile for application/json ContentType.
3014
type PutV1UserzProfileJSONRequestBody = UserUpdateRequest
3015

3016
// AsServiceVersion returns the union data inside the AggregatedVersion_Worker as a ServiceVersion
3017
func (t AggregatedVersion_Worker) AsServiceVersion() (ServiceVersion, error) {
3018
    var body ServiceVersion
3019
    err := json.Unmarshal(t.union, &body)
3020
    return body, err
3021
}
3022

3023
// FromServiceVersion overwrites any union data inside the AggregatedVersion_Worker as the provided ServiceVersion
3024
func (t *AggregatedVersion_Worker) FromServiceVersion(v ServiceVersion) error {
3025
    b, err := json.Marshal(v)
3026
    t.union = b
3027
    return err
3028
}
3029

3030
// MergeServiceVersion performs a merge with any union data inside the AggregatedVersion_Worker, using the provided ServiceVersion
3031
func (t *AggregatedVersion_Worker) MergeServiceVersion(v ServiceVersion) error {
3032
    b, err := json.Marshal(v)
3033
    if err != nil {
3034
        return err
3035
    }
3036

3037
    merged, err := runtime.JSONMerge(t.union, b)
3038
    t.union = merged
3039
    return err
3040
}
3041

3042
// AsAggregatedVersionWorker1 returns the union data inside the AggregatedVersion_Worker as a AggregatedVersionWorker1
3043
func (t AggregatedVersion_Worker) AsAggregatedVersionWorker1() (AggregatedVersionWorker1, error) {
3044
    var body AggregatedVersionWorker1
3045
    err := json.Unmarshal(t.union, &body)
3046
    return body, err
3047
}
3048

3049
// FromAggregatedVersionWorker1 overwrites any union data inside the AggregatedVersion_Worker as the provided AggregatedVersionWorker1
3050
func (t *AggregatedVersion_Worker) FromAggregatedVersionWorker1(v AggregatedVersionWorker1) error {
3051
    b, err := json.Marshal(v)
3052
    t.union = b
3053
    return err
3054
}
3055

3056
// MergeAggregatedVersionWorker1 performs a merge with any union data inside the AggregatedVersion_Worker, using the provided AggregatedVersionWorker1
3057
func (t *AggregatedVersion_Worker) MergeAggregatedVersionWorker1(v AggregatedVersionWorker1) error {
3058
    b, err := json.Marshal(v)
3059
    if err != nil {
3060
        return err
3061
    }
3062

3063
    merged, err := runtime.JSONMerge(t.union, b)
3064
    t.union = merged
3065
    return err
3066
}
3067

3068
func (t AggregatedVersion_Worker) MarshalJSON() ([]byte, error) {
3069
    b, err := t.union.MarshalJSON()
3070
    return b, err
3071
}
3072

3073
func (t *AggregatedVersion_Worker) UnmarshalJSON(b []byte) error {
3074
    err := t.union.UnmarshalJSON(b)
3075
    return err
3076
}
3077

3078
// AsUserSettings0 returns the union data inside the UserSettings as a UserSettings0
3079
func (t UserSettings) AsUserSettings0() (UserSettings0, error) {
3080
    var body UserSettings0
3081
    err := json.Unmarshal(t.union, &body)
3082
    return body, err
3083
}
3084

3085
// FromUserSettings0 overwrites any union data inside the UserSettings as the provided UserSettings0
3086
func (t *UserSettings) FromUserSettings0(v UserSettings0) error {
3087
    b, err := json.Marshal(v)
3088
    t.union = b
3089
    return err
3090
}
3091

3092
// MergeUserSettings0 performs a merge with any union data inside the UserSettings, using the provided UserSettings0
3093
func (t *UserSettings) MergeUserSettings0(v UserSettings0) error {
3094
    b, err := json.Marshal(v)
3095
    if err != nil {
3096
        return err
3097
    }
3098

3099
    merged, err := runtime.JSONMerge(t.union, b)
3100
    t.union = merged
3101
    return err
3102
}
3103

3104
// AsUserSettings1 returns the union data inside the UserSettings as a UserSettings1
3105
func (t UserSettings) AsUserSettings1() (UserSettings1, error) {
3106
    var body UserSettings1
3107
    err := json.Unmarshal(t.union, &body)
3108
    return body, err
3109
}
3110

3111
// FromUserSettings1 overwrites any union data inside the UserSettings as the provided UserSettings1
3112
func (t *UserSettings) FromUserSettings1(v UserSettings1) error {
3113
    b, err := json.Marshal(v)
3114
    t.union = b
3115
    return err
3116
}
3117

3118
// MergeUserSettings1 performs a merge with any union data inside the UserSettings, using the provided UserSettings1
3119
func (t *UserSettings) MergeUserSettings1(v UserSettings1) error {
3120
    b, err := json.Marshal(v)
3121
    if err != nil {
3122
        return err
3123
    }
3124

3125
    merged, err := runtime.JSONMerge(t.union, b)
3126
    t.union = merged
3127
    return err
3128
}
3129

3130
func (t UserSettings) MarshalJSON() ([]byte, error) {
3131
    b, err := t.union.MarshalJSON()
3132
    if err != nil {
3133
        return nil, err
3134
    }
3135
    object := make(map[string]json.RawMessage)
3136
    if t.union != nil {
3137
        err = json.Unmarshal(b, &object)
3138
        if err != nil {
3139
            return nil, err
3140
        }
3141
    }
3142

3143
    if t.AiEnabled != nil {
3144
        object["ai_enabled"], err = json.Marshal(t.AiEnabled)
3145
        if err != nil {
3146
            return nil, fmt.Errorf("error marshaling 'ai_enabled': %w", err)
3147
        }
3148
    }
3149

3150
    if t.AiModel != nil {
3151
        object["ai_model"], err = json.Marshal(t.AiModel)
3152
        if err != nil {
3153
            return nil, fmt.Errorf("error marshaling 'ai_model': %w", err)
3154
        }
3155
    }
3156

3157
    if t.AiProvider != nil {
3158
        object["ai_provider"], err = json.Marshal(t.AiProvider)
3159
        if err != nil {
3160
            return nil, fmt.Errorf("error marshaling 'ai_provider': %w", err)
3161
        }
3162
    }
3163

3164
    if t.ApiKey != nil {
3165
        object["api_key"], err = json.Marshal(t.ApiKey)
3166
        if err != nil {
3167
            return nil, fmt.Errorf("error marshaling 'api_key': %w", err)
3168
        }
3169
    }
3170

3171
    if t.Language != nil {
3172
        object["language"], err = json.Marshal(t.Language)
3173
        if err != nil {
3174
            return nil, fmt.Errorf("error marshaling 'language': %w", err)
3175
        }
3176
    }
3177

3178
    if t.Level != nil {
3179
        object["level"], err = json.Marshal(t.Level)
3180
        if err != nil {
3181
            return nil, fmt.Errorf("error marshaling 'level': %w", err)
3182
        }
3183
    }
3184
    b, err = json.Marshal(object)
3185
    return b, err
3186
}
3187

3188
func (t *UserSettings) UnmarshalJSON(b []byte) error {
3189
    err := t.union.UnmarshalJSON(b)
3190
    if err != nil {
3191
        return err
3192
    }
3193
    object := make(map[string]json.RawMessage)
3194
    err = json.Unmarshal(b, &object)
3195
    if err != nil {
3196
        return err
3197
    }
3198

3199
    if raw, found := object["ai_enabled"]; found {
3200
        err = json.Unmarshal(raw, &t.AiEnabled)
3201
        if err != nil {
3202
            return fmt.Errorf("error reading 'ai_enabled': %w", err)
3203
        }
3204
    }
3205

3206
    if raw, found := object["ai_model"]; found {
3207
        err = json.Unmarshal(raw, &t.AiModel)
3208
        if err != nil {
3209
            return fmt.Errorf("error reading 'ai_model': %w", err)
3210
        }
3211
    }
3212

3213
    if raw, found := object["ai_provider"]; found {
3214
        err = json.Unmarshal(raw, &t.AiProvider)
3215
        if err != nil {
3216
            return fmt.Errorf("error reading 'ai_provider': %w", err)
3217
        }
3218
    }
3219

3220
    if raw, found := object["api_key"]; found {
3221
        err = json.Unmarshal(raw, &t.ApiKey)
3222
        if err != nil {
3223
            return fmt.Errorf("error reading 'api_key': %w", err)
3224
        }
3225
    }
3226

3227
    if raw, found := object["language"]; found {
3228
        err = json.Unmarshal(raw, &t.Language)
3229
        if err != nil {
3230
            return fmt.Errorf("error reading 'language': %w", err)
3231
        }
3232
    }
3233

3234
    if raw, found := object["level"]; found {
3235
        err = json.Unmarshal(raw, &t.Level)
3236
        if err != nil {
3237
            return fmt.Errorf("error reading 'level': %w", err)
3238
        }
3239
    }
3240

3241
    return err
3242
}
3243

3244
// AsUserUpdateRequest0 returns the union data inside the UserUpdateRequest as a UserUpdateRequest0
3245
func (t UserUpdateRequest) AsUserUpdateRequest0() (UserUpdateRequest0, error) {
3246
    var body UserUpdateRequest0
3247
    err := json.Unmarshal(t.union, &body)
3248
    return body, err
3249
}
3250

3251
// FromUserUpdateRequest0 overwrites any union data inside the UserUpdateRequest as the provided UserUpdateRequest0
3252
func (t *UserUpdateRequest) FromUserUpdateRequest0(v UserUpdateRequest0) error {
3253
    b, err := json.Marshal(v)
3254
    t.union = b
3255
    return err
3256
}
3257

3258
// MergeUserUpdateRequest0 performs a merge with any union data inside the UserUpdateRequest, using the provided UserUpdateRequest0
3259
func (t *UserUpdateRequest) MergeUserUpdateRequest0(v UserUpdateRequest0) error {
3260
    b, err := json.Marshal(v)
3261
    if err != nil {
3262
        return err
3263
    }
3264

3265
    merged, err := runtime.JSONMerge(t.union, b)
3266
    t.union = merged
3267
    return err
3268
}
3269

3270
// AsUserUpdateRequest1 returns the union data inside the UserUpdateRequest as a UserUpdateRequest1
3271
func (t UserUpdateRequest) AsUserUpdateRequest1() (UserUpdateRequest1, error) {
3272
    var body UserUpdateRequest1
3273
    err := json.Unmarshal(t.union, &body)
3274
    return body, err
3275
}
3276

3277
// FromUserUpdateRequest1 overwrites any union data inside the UserUpdateRequest as the provided UserUpdateRequest1
3278
func (t *UserUpdateRequest) FromUserUpdateRequest1(v UserUpdateRequest1) error {
3279
    b, err := json.Marshal(v)
3280
    t.union = b
3281
    return err
3282
}
3283

3284
// MergeUserUpdateRequest1 performs a merge with any union data inside the UserUpdateRequest, using the provided UserUpdateRequest1
3285
func (t *UserUpdateRequest) MergeUserUpdateRequest1(v UserUpdateRequest1) error {
3286
    b, err := json.Marshal(v)
3287
    if err != nil {
3288
        return err
3289
    }
3290

3291
    merged, err := runtime.JSONMerge(t.union, b)
3292
    t.union = merged
3293
    return err
3294
}
3295

3296
func (t UserUpdateRequest) MarshalJSON() ([]byte, error) {
3297
    b, err := t.union.MarshalJSON()
3298
    if err != nil {
3299
        return nil, err
3300
    }
3301
    object := make(map[string]json.RawMessage)
3302
    if t.union != nil {
3303
        err = json.Unmarshal(b, &object)
3304
        if err != nil {
3305
            return nil, err
3306
        }
3307
    }
3308

3309
    if t.AiEnabled != nil {
3310
        object["ai_enabled"], err = json.Marshal(t.AiEnabled)
3311
        if err != nil {
3312
            return nil, fmt.Errorf("error marshaling 'ai_enabled': %w", err)
3313
        }
3314
    }
3315

3316
    if t.AiModel != nil {
3317
        object["ai_model"], err = json.Marshal(t.AiModel)
3318
        if err != nil {
3319
            return nil, fmt.Errorf("error marshaling 'ai_model': %w", err)
3320
        }
3321
    }
3322

3323
    if t.AiProvider != nil {
3324
        object["ai_provider"], err = json.Marshal(t.AiProvider)
3325
        if err != nil {
3326
            return nil, fmt.Errorf("error marshaling 'ai_provider': %w", err)
3327
        }
3328
    }
3329

3330
    if t.ApiKey != nil {
3331
        object["api_key"], err = json.Marshal(t.ApiKey)
3332
        if err != nil {
3333
            return nil, fmt.Errorf("error marshaling 'api_key': %w", err)
3334
        }
3335
    }
3336

3337
    if t.CurrentLevel != nil {
3338
        object["current_level"], err = json.Marshal(t.CurrentLevel)
3339
        if err != nil {
3340
            return nil, fmt.Errorf("error marshaling 'current_level': %w", err)
3341
        }
3342
    }
3343

3344
    if t.Email != nil {
3345
        object["email"], err = json.Marshal(t.Email)
3346
        if err != nil {
3347
            return nil, fmt.Errorf("error marshaling 'email': %w", err)
3348
        }
3349
    }
3350

3351
    if t.PreferredLanguage != nil {
3352
        object["preferred_language"], err = json.Marshal(t.PreferredLanguage)
3353
        if err != nil {
3354
            return nil, fmt.Errorf("error marshaling 'preferred_language': %w", err)
3355
        }
3356
    }
3357

3358
    if t.SelectedRoles != nil {
3359
        object["selectedRoles"], err = json.Marshal(t.SelectedRoles)
3360
        if err != nil {
3361
            return nil, fmt.Errorf("error marshaling 'selectedRoles': %w", err)
3362
        }
3363
    }
3364

3365
    if t.Timezone != nil {
3366
        object["timezone"], err = json.Marshal(t.Timezone)
3367
        if err != nil {
3368
            return nil, fmt.Errorf("error marshaling 'timezone': %w", err)
3369
        }
3370
    }
3371

3372
    if t.Username != nil {
3373
        object["username"], err = json.Marshal(t.Username)
3374
        if err != nil {
3375
            return nil, fmt.Errorf("error marshaling 'username': %w", err)
3376
        }
3377
    }
3378
    b, err = json.Marshal(object)
3379
    return b, err
3380
}
3381

3382
func (t *UserUpdateRequest) UnmarshalJSON(b []byte) error {
3383
    err := t.union.UnmarshalJSON(b)
3384
    if err != nil {
3385
        return err
3386
    }
3387
    object := make(map[string]json.RawMessage)
3388
    err = json.Unmarshal(b, &object)
3389
    if err != nil {
3390
        return err
3391
    }
3392

3393
    if raw, found := object["ai_enabled"]; found {
3394
        err = json.Unmarshal(raw, &t.AiEnabled)
3395
        if err != nil {
3396
            return fmt.Errorf("error reading 'ai_enabled': %w", err)
3397
        }
3398
    }
3399

3400
    if raw, found := object["ai_model"]; found {
3401
        err = json.Unmarshal(raw, &t.AiModel)
3402
        if err != nil {
3403
            return fmt.Errorf("error reading 'ai_model': %w", err)
3404
        }
3405
    }
3406

3407
    if raw, found := object["ai_provider"]; found {
3408
        err = json.Unmarshal(raw, &t.AiProvider)
3409
        if err != nil {
3410
            return fmt.Errorf("error reading 'ai_provider': %w", err)
3411
        }
3412
    }
3413

3414
    if raw, found := object["api_key"]; found {
3415
        err = json.Unmarshal(raw, &t.ApiKey)
3416
        if err != nil {
3417
            return fmt.Errorf("error reading 'api_key': %w", err)
3418
        }
3419
    }
3420

3421
    if raw, found := object["current_level"]; found {
3422
        err = json.Unmarshal(raw, &t.CurrentLevel)
3423
        if err != nil {
3424
            return fmt.Errorf("error reading 'current_level': %w", err)
3425
        }
3426
    }
3427

3428
    if raw, found := object["email"]; found {
3429
        err = json.Unmarshal(raw, &t.Email)
3430
        if err != nil {
3431
            return fmt.Errorf("error reading 'email': %w", err)
3432
        }
3433
    }
3434

3435
    if raw, found := object["preferred_language"]; found {
3436
        err = json.Unmarshal(raw, &t.PreferredLanguage)
3437
        if err != nil {
3438
            return fmt.Errorf("error reading 'preferred_language': %w", err)
3439
        }
3440
    }
3441

3442
    if raw, found := object["selectedRoles"]; found {
3443
        err = json.Unmarshal(raw, &t.SelectedRoles)
3444
        if err != nil {
3445
            return fmt.Errorf("error reading 'selectedRoles': %w", err)
3446
        }
3447
    }
3448

3449
    if raw, found := object["timezone"]; found {
3450
        err = json.Unmarshal(raw, &t.Timezone)
3451
        if err != nil {
3452
            return fmt.Errorf("error reading 'timezone': %w", err)
3453
        }
3454
    }
3455

3456
    if raw, found := object["username"]; found {
3457
        err = json.Unmarshal(raw, &t.Username)
3458
        if err != nil {
3459
            return fmt.Errorf("error reading 'username': %w", err)
3460
        }
3461
    }
3462

3463
    return err
3464
}
3465


			
quizapp internal config
79.8%
Statements
162/203
config.go
79.8%
162/203
quizapp internal config config.go
79.8%
Statements
162/203
1
// Package config handles application configuration loading from environment variables.
2
package config
3

4
import (
5
    "fmt"
6
    "os"
7
    "reflect"
8
    "sort"
9
    "strconv"
10
    "strings"
11
    "time"
12

13
    contextutils "quizapp/internal/utils"
14

15
    "gopkg.in/yaml.v3"
16
)
17

18
// ProviderConfig defines the structure for a single provider
19
type ProviderConfig struct {
20
    Name              string    `json:"name" yaml:"name"`
21
    Code              string    `json:"code" yaml:"code"`
22
    URL               string    `json:"url,omitempty" yaml:"url,omitempty"`
23
    SupportsGrammar   bool      `json:"supports_grammar" yaml:"supports_grammar"`
24
    UsageSupported    bool      `json:"usage_supported" yaml:"usage_supported"`
25
    QuestionBatchSize int       `json:"question_batch_size,omitempty" yaml:"question_batch_size,omitempty"`
26
    Models            []AIModel `json:"models" yaml:"models"`
27
}
28

29
// AIModel represents an AI model configuration
30
type AIModel struct {
31
    Name      string `json:"name" yaml:"name"`
32
    Code      string `json:"code" yaml:"code"`
33
    MaxTokens int    `json:"max_tokens,omitempty" yaml:"max_tokens,omitempty"`
34
}
35

36
// QuestionVarietyConfig defines the variety configuration for question generation
37
type QuestionVarietyConfig struct {
38
    TopicCategories     []string            `json:"topic_categories" yaml:"topic_categories"`
39
    GrammarFocusByLevel map[string][]string `json:"grammar_focus_by_level" yaml:"grammar_focus_by_level"`
40
    GrammarFocus        []string            `json:"grammar_focus" yaml:"grammar_focus"`
41
    VocabularyDomains   []string            `json:"vocabulary_domains" yaml:"vocabulary_domains"`
42
    Scenarios           []string            `json:"scenarios" yaml:"scenarios"`
43
    StyleModifiers      []string            `json:"style_modifiers" yaml:"style_modifiers"`
44
    DifficultyModifiers []string            `json:"difficulty_modifiers" yaml:"difficulty_modifiers"`
45
    TimeContexts        []string            `json:"time_contexts" yaml:"time_contexts"`
46
}
47

48
// LanguageLevelConfig represents the levels and descriptions for a specific language
49
type LanguageLevelConfig struct {
50
    Code         string            `json:"code" yaml:"code"`
51
    TtsLocale    string            `json:"tts_locale" yaml:"tts_locale"`
52
    TtsVoice     string            `json:"tts_voice" yaml:"tts_voice"`
53
    Levels       []string          `json:"levels" yaml:"levels"`
54
    Descriptions map[string]string `json:"descriptions" yaml:"descriptions"`
55
}
56

57
// LanguageInfo represents a language with its code and human-readable name
58
type LanguageInfo struct {
59
    Code      string  `json:"code"`
60
    Name      string  `json:"name"`
61
    TtsLocale *string `json:"tts_locale,omitempty"`
62
    TtsVoice  *string `json:"tts_voice,omitempty"`
63
}
64

65
// AuthConfig represents authentication-related configuration
66
type AuthConfig struct {
67
    SignupsDisabled bool     `json:"signups_disabled" yaml:"signups_disabled"`
68
    AllowedDomains  []string `json:"allowed_domains,omitempty" yaml:"allowed_domains,omitempty"`
69
    AllowedEmails   []string `json:"allowed_emails,omitempty" yaml:"allowed_emails,omitempty"`
70
}
71

72
// SystemConfig represents system-wide configuration
73
type SystemConfig struct {
74
    Auth AuthConfig `json:"auth" yaml:"auth"`
75
}
76

77
// Config holds all configuration for the application
78
type Config struct {
79
    // Server configuration
80
    Server ServerConfig `json:"server" yaml:"server"`
81

82
    // Database configuration
83
    Database DatabaseConfig `json:"database" yaml:"database"`
84

85
    // AI Providers and Language Levels
86
    Providers      []ProviderConfig               `json:"providers" yaml:"providers"`
87
    LanguageLevels map[string]LanguageLevelConfig `json:"language_levels" yaml:"language_levels"`
88
    Variety        *QuestionVarietyConfig         `json:"variety,omitempty" yaml:"variety,omitempty"`
89
    System         *SystemConfig                  `json:"system,omitempty" yaml:"system,omitempty"`
90

91
    // OAuth Configuration
92
    GoogleOAuthClientID     string `json:"google_oauth_client_id" yaml:"google_oauth_client_id"`
93
    GoogleOAuthClientSecret string `json:"google_oauth_client_secret" yaml:"google_oauth_client_secret"`
94
    GoogleOAuthRedirectURL  string `json:"google_oauth_redirect_url" yaml:"google_oauth_redirect_url"`
95

96
    // OpenTelemetry Configuration
97
    OpenTelemetry OpenTelemetryConfig `json:"open_telemetry" yaml:"open_telemetry"`
98

99
    // Email Configuration
100
    Email EmailConfig `json:"email" yaml:"email"`
101

102
    // Story Configuration
103
    Story StoryConfig `json:"story" yaml:"story"`
104

105
    // Translation Configuration
106
    Translation TranslationConfig `json:"translation" yaml:"translation"`
107

108
    // Linear Configuration
109
    Linear LinearConfig `json:"linear" yaml:"linear"`
110

111
    // Internal fields
112
    IsTest bool `json:"is_test" yaml:"is_test"`
113
}
114

115
// ServerConfig represents server configuration
116
type ServerConfig struct {
117
    Port                    string   `json:"port" yaml:"port"`
118
    WorkerPort              string   `json:"worker_port" yaml:"worker_port"`
119
    AdminUsername           string   `json:"admin_username" yaml:"admin_username"`
120
    AdminPassword           string   `json:"admin_password" yaml:"admin_password"`
121
    SessionSecret           string   `json:"session_secret" yaml:"session_secret"`
122
    Debug                   bool     `json:"debug" yaml:"debug"`
123
    LogLevel                string   `json:"log_level" yaml:"log_level"`
124
    WorkerBaseURL           string   `json:"worker_base_url" yaml:"worker_base_url"`
125
    WorkerInternalURL       string   `json:"worker_internal_url" yaml:"worker_internal_url"`
126
    BackendBaseURL          string   `json:"backend_base_url" yaml:"backend_base_url"`
127
    AppBaseURL              string   `json:"app_base_url" yaml:"app_base_url"`
128
    MaxAIConcurrent         int      `json:"max_ai_concurrent" yaml:"max_ai_concurrent"`
129
    MaxAIPerUser            int      `json:"max_ai_per_user" yaml:"max_ai_per_user"`
130
    CORSOrigins             []string `json:"cors_origins" yaml:"cors_origins"`
131
    QuestionRefillThreshold int      `json:"question_refill_threshold" yaml:"question_refill_threshold"`
132
    // DailyFreshQuestionRatio controls the minimum fraction of fresh (never-seen)
133
    // questions to aim for when refilling question pools (0.0 - 1.0). Example: 0.35
134
    // means at least 35% fresh questions when refilling.
135
    DailyFreshQuestionRatio float64 `json:"daily_fresh_question_ratio" yaml:"daily_fresh_question_ratio"`
136
    MaxHistory              int     `json:"max_history" yaml:"max_history"`
137
    MaxActivityLogs         int     `json:"max_activity_logs" yaml:"max_activity_logs"`
138
    DailyRepeatAvoidDays    int     `json:"daily_repeat_avoid_days" yaml:"daily_repeat_avoid_days"`
139
    // DailyHorizonDays controls how many days ahead the worker will assign
140
    // daily questions (e.g. 0 = today only, 1 = today+1, ...). If unset or
141
    // <= 0 the worker will fall back to the DAILY_HORIZON_DAYS environment
142
    // variable (default 1).
143
    DailyHorizonDays int `json:"daily_horizon_days" yaml:"daily_horizon_days"`
144
}
145

146
// GetLanguages returns a slice of all supported languages (derived from language_levels keys)
147
4x
func (c *Config) GetLanguages() []string {
148
4x
    if c.LanguageLevels == nil {
149
        return []string{}
150
    }
151

152
4x
    languages := make([]string, 0, len(c.LanguageLevels))
153
4x
    for lang := range c.LanguageLevels {
154
24x
        languages = append(languages, lang)
155
24x
    }
156

157
4x
    sort.Strings(languages)
158
4x
    return languages
159
}
160

161
// GetLanguageInfoList returns a slice of language info objects with code and name
162
func (c *Config) GetLanguageInfoList() []LanguageInfo {
163
    if c.LanguageLevels == nil {
164
        return []LanguageInfo{}
165
    }
166

167
    languageInfos := make([]LanguageInfo, 0, len(c.LanguageLevels))
168
    for langName, langConfig := range c.LanguageLevels {
169
        var ttsLocale, ttsVoice *string
170
        if langConfig.TtsLocale != "" {
171
            ttsLocale = &langConfig.TtsLocale
172
        }
173
        if langConfig.TtsVoice != "" {
174
            ttsVoice = &langConfig.TtsVoice
175
        }
176

177
        languageInfos = append(languageInfos, LanguageInfo{
178
            Code:      langConfig.Code,
179
            Name:      langName,
180
            TtsLocale: ttsLocale,
181
            TtsVoice:  ttsVoice,
182
        })
183
    }
184

185
    // Sort by name for consistent ordering
186
    sort.Slice(languageInfos, func(i, j int) bool {
187
        return languageInfos[i].Name < languageInfos[j].Name
188
    })
189

190
    return languageInfos
191
}
192

193
// GetLevelsForLanguage returns the levels for a specific language
194
11x
func (c *Config) GetLevelsForLanguage(language string) []string {
195
11x
    if c.LanguageLevels == nil {
196
        return []string{}
197
    }
198

199
    // First try to look up by language name directly
200
11x
    if langConfig, exists := c.LanguageLevels[language]; exists {
201
5x
        return langConfig.Levels
202
5x
    }
203

204
    // If not found by name, try to find by language code
205
6x
    for _, langConfig := range c.LanguageLevels {
206
28x
        if langConfig.Code == language {
207
3x
            return langConfig.Levels
208
3x
        }
209
    }
210

211
3x
    return []string{}
212
}
213

214
// GetLevelDescriptionsForLanguage returns the level descriptions for a specific language
215
9x
func (c *Config) GetLevelDescriptionsForLanguage(language string) map[string]string {
216
9x
    if c.LanguageLevels == nil {
217
        return map[string]string{}
218
    }
219

220
    // First try to look up by language name directly
221
9x
    if langConfig, exists := c.LanguageLevels[language]; exists {
222
4x
        return langConfig.Descriptions
223
4x
    }
224

225
    // If not found by name, try to find by language code
226
5x
    for _, langConfig := range c.LanguageLevels {
227
19x
        if langConfig.Code == language {
228
2x
            return langConfig.Descriptions
229
2x
        }
230
    }
231

232
3x
    return map[string]string{}
233
}
234

235
// GetAllLevels returns all unique levels across all languages
236
4x
func (c *Config) GetAllLevels() []string {
237
4x
    if c.LanguageLevels == nil {
238
        return []string{}
239
    }
240

241
4x
    levelSet := make(map[string]bool)
242
4x
    for _, langConfig := range c.LanguageLevels {
243
22x
        for _, level := range langConfig.Levels {
244
146x
            levelSet[level] = true
245
146x
        }
246
    }
247

248
4x
    levels := make([]string, 0, len(levelSet))
249
4x
    for level := range levelSet {
250
46x
        levels = append(levels, level)
251
46x
    }
252

253
4x
    sort.Strings(levels)
254
4x
    return levels
255
}
256

257
// GetAllLevelDescriptions returns all unique level descriptions across all languages
258
4x
func (c *Config) GetAllLevelDescriptions() map[string]string {
259
4x
    if c.LanguageLevels == nil {
260
        return map[string]string{}
261
    }
262

263
4x
    descriptions := make(map[string]string)
264
4x
    for _, langConfig := range c.LanguageLevels {
265
22x
        for level, description := range langConfig.Descriptions {
266
142x
            descriptions[level] = description
267
142x
        }
268
    }
269

270
4x
    return descriptions
271
}
272

273
// Languages returns all supported languages
274
1x
func (c *Config) Languages() []string {
275
1x
    return c.GetLanguages()
276
1x
}
277

278
// Levels returns all unique levels
279
1x
func (c *Config) Levels() []string {
280
1x
    return c.GetAllLevels()
281
1x
}
282

283
// LevelDescriptions returns all unique level descriptions
284
1x
func (c *Config) LevelDescriptions() map[string]string {
285
1x
    return c.GetAllLevelDescriptions()
286
1x
}
287

288
// IsSignupDisabled returns whether signups are disabled based on configuration
289
4x
func (c *Config) IsSignupDisabled() bool {
290
4x
    if c.System == nil {
291
1x
        return false // Default to enabled if no config
292
1x
    }
293
3x
    return c.System.Auth.SignupsDisabled
294
}
295

296
// IsEmailAllowed checks if an email is allowed for OAuth signup override
297
19x
func (c *Config) IsEmailAllowed(email string) bool {
298
19x
    if c.System == nil || c.System.Auth.AllowedEmails == nil {
299
4x
        return false
300
4x
    }
301

302
15x
    normalizedEmail := strings.ToLower(strings.TrimSpace(email))
303
15x
    for _, allowedEmail := range c.System.Auth.AllowedEmails {
304
16x
        if strings.ToLower(strings.TrimSpace(allowedEmail)) == normalizedEmail {
305
6x
            return true
306
6x
        }
307
    }
308
9x
    return false
309
}
310

311
// IsDomainAllowed checks if a domain is allowed for OAuth signup override
312
16x
func (c *Config) IsDomainAllowed(domain string) bool {
313
16x
    if c.System == nil || c.System.Auth.AllowedDomains == nil {
314
4x
        return false
315
4x
    }
316

317
12x
    normalizedDomain := strings.ToLower(strings.TrimSpace(domain))
318
12x
    for _, allowedDomain := range c.System.Auth.AllowedDomains {
319
13x
        if strings.ToLower(strings.TrimSpace(allowedDomain)) == normalizedDomain {
320
6x
            return true
321
6x
        }
322
    }
323
6x
    return false
324
}
325

326
// IsOAuthSignupAllowed checks if OAuth signup is allowed for a given email
327
17x
func (c *Config) IsOAuthSignupAllowed(email string) bool {
328
17x
    if c.System == nil {
329
1x
        return false
330
1x
    }
331

332
    // If signups are not disabled, OAuth signup is always allowed
333
16x
    if !c.System.Auth.SignupsDisabled {
334
2x
        return true
335
2x
    }
336

337
    // If signups are disabled, check whitelist
338
14x
    normalizedEmail := strings.ToLower(strings.TrimSpace(email))
339
14x

340
14x
    // Use the shared email validation function
341
14x
    if !contextutils.IsValidEmail(normalizedEmail) {
342
4x
        return false
343
4x
    }
344

345
    // Check if email is directly whitelisted
346
10x
    if c.IsEmailAllowed(normalizedEmail) {
347
2x
        return true
348
2x
    }
349

350
    // Extract domain from email and check if domain is whitelisted
351
8x
    parts := strings.Split(normalizedEmail, "@")
352
8x
    domain := parts[1]
353
8x
    return c.IsDomainAllowed(domain)
354
}
355

356
// OpenTelemetryConfig holds all OpenTelemetry-related configuration
357
type OpenTelemetryConfig struct {
358
    Endpoint       string            `json:"endpoint" yaml:"endpoint"`               // Default: "http://localhost:4317"
359
    Protocol       string            `json:"protocol" yaml:"protocol"`               // "grpc" or "http", default: "grpc"
360
    Insecure       bool              `json:"insecure" yaml:"insecure"`               // Default: true (for localhost)
361
    Headers        map[string]string `json:"headers" yaml:"headers"`                 // For authenticated endpoints
362
    ServiceName    string            `json:"service_name" yaml:"service_name"`       // Default: "quiz-backend" or "quiz-worker"
363
    ServiceVersion string            `json:"service_version" yaml:"service_version"` // From version package
364
    EnableTracing  bool              `json:"enable_tracing" yaml:"enable_tracing"`   // Default: true
365
    EnableMetrics  bool              `json:"enable_metrics" yaml:"enable_metrics"`   // Default: true
366
    EnableLogging  bool              `json:"enable_logging" yaml:"enable_logging"`   // Default: true (future)
367
    SamplingRate   float64           `json:"sampling_rate" yaml:"sampling_rate"`     // Default: 1.0 (100%)
368
    UseAutoSDK     bool              `json:"use_auto_sdk" yaml:"use_auto_sdk"`       // Default: true (use Auto SDK, false for standard SDK)
369
}
370

371
// DatabaseConfig represents database configuration
372
type DatabaseConfig struct {
373
    URL             string        `json:"url" yaml:"url"`
374
    MaxOpenConns    int           `json:"max_open_conns" yaml:"max_open_conns"`       // Maximum number of open connections to the database
375
    MaxIdleConns    int           `json:"max_idle_conns" yaml:"max_idle_conns"`       // Maximum number of idle connections in the pool
376
    ConnMaxLifetime time.Duration `json:"conn_max_lifetime" yaml:"conn_max_lifetime"` // Maximum amount of time a connection may be reused
377
}
378

379
// EmailConfig represents email/SMTP configuration
380
type EmailConfig struct {
381
    SMTP          SMTPConfig          `json:"smtp" yaml:"smtp"`
382
    DailyReminder DailyReminderConfig `json:"daily_reminder" yaml:"daily_reminder"`
383
    Enabled       bool                `json:"enabled" yaml:"enabled"`
384
}
385

386
// SMTPConfig represents SMTP server configuration
387
type SMTPConfig struct {
388
    Host        string `json:"host" yaml:"host"`
389
    Port        int    `json:"port" yaml:"port"`
390
    Username    string `json:"username" yaml:"username"`
391
    Password    string `json:"password" yaml:"password"`
392
    FromAddress string `json:"from_address" yaml:"from_address"`
393
    FromName    string `json:"from_name" yaml:"from_name"`
394
}
395

396
// DailyReminderConfig represents daily reminder email configuration
397
type DailyReminderConfig struct {
398
    Enabled bool `json:"enabled" yaml:"enabled"`
399
    Hour    int  `json:"hour" yaml:"hour"` // Hour of day to send (0-23)
400
}
401

402
// StorySectionLengthsConfig represents section length configuration by proficiency level
403
type StorySectionLengthsConfig struct {
404
    Beginner          map[string]int                       `json:"beginner" yaml:"beginner"`
405
    Elementary        map[string]int                       `json:"elementary" yaml:"elementary"`
406
    Intermediate      map[string]int                       `json:"intermediate" yaml:"intermediate"`
407
    UpperIntermediate map[string]int                       `json:"upper_intermediate" yaml:"upper_intermediate"`
408
    Advanced          map[string]int                       `json:"advanced" yaml:"advanced"`
409
    Proficient        map[string]int                       `json:"proficient" yaml:"proficient"`
410
    Overrides         map[string]map[string]map[string]int `json:"overrides" yaml:"overrides"`
411
}
412

413
// StoryConfig represents story mode configuration
414
type StoryConfig struct {
415
    MaxArchivedPerUser         int                       `json:"max_archived_per_user" yaml:"max_archived_per_user"`
416
    GenerationEnabled          bool                      `json:"generation_enabled" yaml:"generation_enabled"`
417
    EngagementBasedGeneration  bool                      `json:"engagement_based_generation" yaml:"engagement_based_generation"`
418
    SectionLengths             StorySectionLengthsConfig `json:"section_lengths" yaml:"section_lengths"`
419
    QuestionsPerSection        int                       `json:"questions_per_section" yaml:"questions_per_section"`
420
    MaxExtraGenerationsPerDay  int                       `json:"max_extra_generations_per_day" yaml:"max_extra_generations_per_day"`
421
    MaxWorkerGenerationsPerDay int                       `json:"max_worker_generations_per_day" yaml:"max_worker_generations_per_day"`
422
}
423

424
// TranslationConfig represents translation service configuration
425
type TranslationConfig struct {
426
    Enabled         bool                                 `json:"enabled" yaml:"enabled"`
427
    DefaultProvider string                               `json:"default_provider" yaml:"default_provider"`
428
    Providers       map[string]TranslationProviderConfig `json:"providers" yaml:"providers"`
429
    Quota           TranslationQuotaConfig               `json:"quota" yaml:"quota"`
430
}
431

432
// TranslationProviderConfig represents a translation provider configuration
433
type TranslationProviderConfig struct {
434
    Name          string `json:"name" yaml:"name"`
435
    Code          string `json:"code" yaml:"code"`
436
    APIKey        string `json:"api_key" yaml:"api_key"`
437
    BaseURL       string `json:"base_url" yaml:"base_url"`
438
    APIEndpoint   string `json:"api_endpoint" yaml:"api_endpoint"`
439
    MaxTextLength int    `json:"max_text_length" yaml:"max_text_length"`
440
}
441

442
// TranslationQuotaConfig represents quota configuration for translation services
443
type TranslationQuotaConfig struct {
444
    Enabled bool `json:"enabled" yaml:"enabled"`
445
    // Monthly character quotas per provider
446
    GoogleMonthlyQuota int64 `json:"google_monthly_quota" yaml:"google_monthly_quota"`
447
    // Default monthly quota for new providers (in characters)
448
    DefaultMonthlyQuota int64 `json:"default_monthly_quota" yaml:"default_monthly_quota"`
449
}
450

451
// LinearConfig represents Linear integration configuration
452
type LinearConfig struct {
453
    APIKey        string   `json:"api_key" yaml:"api_key"`               // API key from LINEAR_API_KEY env var
454
    TeamID        string   `json:"team_id" yaml:"team_id"`               // Team ID, override via LINEAR_TEAM_ID
455
    ProjectID     string   `json:"project_id" yaml:"project_id"`         // Project ID, override via LINEAR_PROJECT_ID
456
    DefaultLabels []string `json:"default_labels" yaml:"default_labels"` // Optional default labels
457
    DefaultState  string   `json:"default_state" yaml:"default_state"`   // Optional default state (e.g., "Todo")
458
    Enabled       bool     `json:"enabled" yaml:"enabled"`               // Feature flag
459
}
460

461
// NewConfig loads configuration from YAML file first, then overrides with environment variables
462
32x
func NewConfig() (result0 *Config, err error) {
463
32x
    // Load config from YAML file
464
32x
    config, err := loadConfigWithOverrides()
465
32x
    if err != nil {
466
3x
        return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to load config: %v", err)
467
3x
    }
468

469
    // Override with environment variables
470
29x
    config.overrideFromEnv()
471
29x

472
29x
    return config, nil
473
}
474

475
// overrideFromEnv overrides config values with environment variables using reflection
476
29x
func (c *Config) overrideFromEnv() {
477
29x
    overrideStructFromEnv(c)
478
29x
}
479

480
// overrideStructFromEnv recursively overrides struct fields with environment variables
481
37x
func overrideStructFromEnv(v interface{}) {
482
37x
    overrideStructFromEnvWithPrefix(v, "")
483
37x
}
484

485
// overrideStructFromEnvWithPrefix recursively overrides struct fields with environment variables
486
489x
func overrideStructFromEnvWithPrefix(v interface{}, prefix string) {
487
489x
    val := reflect.ValueOf(v)
488
489x
    if val.Kind() == reflect.Ptr {
489
489x
        val = val.Elem()
490
489x
    }
491

492
489x
    if val.Kind() != reflect.Struct {
493
        return
494
    }
495

496
489x
    typ := val.Type()
497
489x
    for i := 0; i < val.NumField(); i++ {
498
3424x
        field := val.Field(i)
499
3424x
        fieldType := typ.Field(i)
500
3424x

501
3424x
        // Skip unexported fields
502
3424x
        if !field.CanSet() {
503
            continue
504
        }
505

506
        // Get the yaml tag for the field
507
3424x
        yamlTag := fieldType.Tag.Get("yaml")
508
3424x
        if yamlTag == "" || yamlTag == "-" {
509
            continue
510
        }
511

512
        // Convert yaml tag to environment variable name
513
3424x
        envKey := strings.ToUpper(strings.ReplaceAll(yamlTag, "-", "_"))
514
3424x
        if prefix != "" {
515
2869x
            envKey = prefix + "_" + envKey
516
2869x
        }
517

518
3424x
        switch field.Kind() {
519
1036x
        case reflect.String:
520
1036x
            if envVal := os.Getenv(envKey); envVal != "" {
521
246x
                field.SetString(envVal)
522
246x
            }
523
666x
        case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
524
666x
            if envVal := os.Getenv(envKey); envVal != "" {
525
9x
                if intVal, err := strconv.ParseInt(envVal, 10, 64); err == nil {
526
6x
                    field.SetInt(intVal)
527
6x
                }
528
            }
529
        case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
530
            if envVal := os.Getenv(envKey); envVal != "" {
531
                if uintVal, err := strconv.ParseUint(envVal, 10, 64); err == nil {
532
                    field.SetUint(uintVal)
533
                }
534
            }
535
74x
        case reflect.Float32, reflect.Float64:
536
74x
            if envVal := os.Getenv(envKey); envVal != "" {
537
3x
                if floatVal, err := strconv.ParseFloat(envVal, 64); err == nil {
538
2x
                    field.SetFloat(floatVal)
539
2x
                }
540
            }
541
534x
        case reflect.Bool:
542
534x
            if envVal := os.Getenv(envKey); envVal != "" {
543
70x
                if boolVal, err := strconv.ParseBool(envVal); err == nil {
544
68x
                    field.SetBool(boolVal)
545
68x
                }
546
            }
547
234x
        case reflect.Slice:
548
234x
            if envVal := os.Getenv(envKey); envVal != "" {
549
3x
                // Handle string slices (like CORS_ORIGINS)
550
3x
                if field.Type().Elem().Kind() == reflect.String {
551
3x
                    slice := strings.Split(envVal, ",")
552
3x
                    field.Set(reflect.ValueOf(slice))
553
3x
                }
554
            }
555
383x
        case reflect.Map:
556
383x
            // Handle map fields with string keys and struct values
557
383x
            if field.Type().Key().Kind() == reflect.String && field.Type().Elem().Kind() == reflect.Struct {
558
74x
                handleMapFieldOverrides(field, yamlTag, prefix)
559
74x
            }
560
423x
        case reflect.Struct:
561
423x
            // Recursively process nested structs with the field name as prefix
562
423x
            if field.CanAddr() {
563
423x
                fieldPrefix := strings.ToUpper(strings.ReplaceAll(yamlTag, "-", "_"))
564
423x
                if prefix != "" {
565
164x
                    fieldPrefix = prefix + "_" + fieldPrefix
566
164x
                }
567
423x
                overrideStructFromEnvWithPrefix(field.Addr().Interface(), fieldPrefix)
568
            }
569
74x
        case reflect.Ptr:
570
74x
            // Handle pointer to struct
571
74x
            if !field.IsNil() && field.Elem().Kind() == reflect.Struct {
572
29x
                fieldPrefix := strings.ToUpper(strings.ReplaceAll(yamlTag, "-", "_"))
573
29x
                if prefix != "" {
574
                    fieldPrefix = prefix + "_" + fieldPrefix
575
                }
576
29x
                overrideStructFromEnvWithPrefix(field.Interface(), fieldPrefix)
577
            }
578
        }
579
    }
580
}
581

582
// handleMapFieldOverrides handles environment variable overrides for map fields with string keys and struct values
583
74x
func handleMapFieldOverrides(field reflect.Value, yamlTag, parentPrefix string) {
584
74x
    if !field.CanSet() || field.Type().Key().Kind() != reflect.String {
585
        return
586
    }
587

588
    // Build the prefix for environment variables
589
74x
    mapPrefix := strings.ToUpper(strings.ReplaceAll(yamlTag, "-", "_"))
590
74x
    if parentPrefix != "" {
591
37x
        mapPrefix = parentPrefix + "_" + mapPrefix
592
37x
    }
593

594
    // Iterate through all keys in the map and look for corresponding environment variables
595
74x
    for _, key := range field.MapKeys() {
596
146x
        keyName := key.String()
597
146x
        keyVal := field.MapIndex(key)
598
146x

599
146x
        if keyVal.IsValid() && keyVal.Kind() == reflect.Struct {
600
146x
            // Create a new struct with potential overrides
601
146x
            newStruct := createStructWithOverrides(keyVal, keyName, mapPrefix)
602
146x
            if newStruct.IsValid() {
603
13x
                field.SetMapIndex(key, newStruct)
604
13x
            }
605
        }
606
    }
607
}
608

609
// createStructWithOverrides creates a new struct with environment variable overrides applied
610
146x
func createStructWithOverrides(originalStruct reflect.Value, keyName, mapPrefix string) reflect.Value {
611
146x
    if !originalStruct.IsValid() || originalStruct.Kind() != reflect.Struct {
612
        return reflect.Value{}
613
    }
614

615
146x
    structType := originalStruct.Type()
616
146x
    newStruct := reflect.New(structType).Elem()
617
146x
    updated := false
618
146x

619
146x
    for i := 0; i < structType.NumField(); i++ {
620
756x
        fieldInfo := structType.Field(i)
621
756x
        origField := originalStruct.Field(i)
622
756x
        newField := newStruct.Field(i)
623
756x

624
756x
        // Skip unexported fields
625
756x
        if !newField.CanSet() {
626
            continue
627
        }
628

629
        // Get the yaml tag for the field
630
756x
        yamlTag := fieldInfo.Tag.Get("yaml")
631
756x
        if yamlTag == "" || yamlTag == "-" {
632
            // Copy original value for fields without yaml tags
633
            newField.Set(origField)
634
            continue
635
        }
636

637
        // Convert yaml tag to environment variable name
638
756x
        envKey := strings.ToUpper(strings.ReplaceAll(yamlTag, "-", "_"))
639
756x
        envVarName := fmt.Sprintf("%s_%s_%s", mapPrefix, strings.ToUpper(keyName), envKey)
640
756x

641
756x
        envVal := os.Getenv(envVarName)
642
756x
        if envVal != "" {
643
13x
            // Set the field value based on its type
644
13x
            setReflectValue(newField, envVal)
645
13x
            updated = true
646
13x
        } else {
647
743x
            // Copy the original value
648
743x
            newField.Set(origField)
649
743x
        }
650
    }
651

652
146x
    if updated {
653
13x
        return newStruct
654
13x
    }
655
133x
    return reflect.Value{}
656
}
657

658
// setReflectValue sets a reflect.Value from a string environment variable
659
13x
func setReflectValue(field reflect.Value, envVal string) {
660
13x
    if !field.CanSet() {
661
        return
662
    }
663

664
13x
    switch field.Kind() {
665
13x
    case reflect.String:
666
13x
        field.SetString(envVal)
667
    case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
668
        if intVal, err := strconv.ParseInt(envVal, 10, 64); err == nil {
669
            field.SetInt(intVal)
670
        }
671
    case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
672
        if uintVal, err := strconv.ParseUint(envVal, 10, 64); err == nil {
673
            field.SetUint(uintVal)
674
        }
675
    case reflect.Float32, reflect.Float64:
676
        if floatVal, err := strconv.ParseFloat(envVal, 64); err == nil {
677
            field.SetFloat(floatVal)
678
        }
679
    case reflect.Bool:
680
        if boolVal, err := strconv.ParseBool(envVal); err == nil {
681
            field.SetBool(boolVal)
682
        }
683
    }
684
}
685

686
// loadConfigWithOverrides loads the config file with potential local overrides
687
32x
func loadConfigWithOverrides() (result0 *Config, err error) {
688
32x
    // Try to load from environment variable first
689
32x
    if envPath := os.Getenv("QUIZ_CONFIG_FILE"); envPath != "" {
690
32x
        config, err := loadConfigFromFile(envPath)
691
32x
        if err != nil {
692
3x
            return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to load config from %s: %v", envPath, err)
693
3x
        }
694
29x
        return config, nil
695
    }
696

697
    // If no environment variable is set, try default config.yaml
698
    return loadConfigFromFile("config.yaml")
699
}
700

701
// loadConfigFromFile loads configuration from a specific file
702
32x
func loadConfigFromFile(path string) (result0 *Config, err error) {
703
32x
    yamlFile, err := os.ReadFile(path)
704
32x
    if err != nil {
705
3x
        return nil, err
706
3x
    }
707

708
29x
    var config Config
709
29x
    if err := yaml.Unmarshal(yamlFile, &config); err != nil {
710
        return nil, err
711
    }
712

713
29x
    return &config, nil
714
}
715


			
quizapp internal database
81.5%
Statements
181/222
database.go
81.5%
181/222
quizapp internal database database.go
81.5%
Statements
181/222
1
// Package database provides database connection and migration functionality.
2
package database
3

4
import (
5
    "context"
6
    "database/sql"
7
    "errors"
8
    "fmt"
9
    "net/url"
10
    "os"
11
    "path/filepath"
12
    "strings"
13
    "sync"
14

15
    "quizapp/internal/config"
16
    "quizapp/internal/observability"
17
    contextutils "quizapp/internal/utils"
18

19
    // Import PostgreSQL driver for database/sql
20
    _ "github.com/lib/pq"
21

22
    // Add golang-migrate imports
23
    "github.com/golang-migrate/migrate/v4"
24
    _ "github.com/golang-migrate/migrate/v4/database/postgres" // required for golang-migrate postgres driver
25
    _ "github.com/golang-migrate/migrate/v4/source/file"       // required for golang-migrate file source
26

27
    // OpenTelemetry SQL instrumentation
28
    "go.nhat.io/otelsql"
29

30
    "go.opentelemetry.io/otel/attribute"
31
    semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
32
)
33

34
// Manager handles database operations with proper logging
35
type Manager struct {
36
    logger *observability.Logger
37
}
38

39
var (
40
    otelDriverNameCache string
41
    otelDriverOnce      sync.Once
42
    otelDriverErr       error
43
)
44

45
// NewManager creates a new database manager with the provided logger
46
18x
func NewManager(logger *observability.Logger) *Manager {
47
18x
    return &Manager{
48
18x
        logger: logger,
49
18x
    }
50
18x
}
51

52
// ErrTableAlreadyExists is returned when trying to create a table that already exists
53
var ErrTableAlreadyExists = errors.New("table already exists")
54

55
// DefaultDatabaseConfig returns the default database configuration
56
11x
func DefaultDatabaseConfig() config.DatabaseConfig {
57
11x
    config := config.DatabaseConfig{
58
11x
        MaxOpenConns:    25,
59
11x
        MaxIdleConns:    5,
60
11x
        ConnMaxLifetime: config.DatabaseConnMaxLifetime,
61
11x
    }
62
11x

63
11x
    // Check for TEST_DATABASE_URL first (for tests)
64
11x
    if testURL := os.Getenv("TEST_DATABASE_URL"); testURL != "" {
65
11x
        config.URL = testURL
66
11x
    }
67

68
11x
    return config
69
}
70

71
// InitDB initializes and returns a database connection with migrations
72
4x
func (dm *Manager) InitDB(databaseURL string) (result0 *sql.DB, err error) {
73
4x
    dbName := extractDatabaseName(databaseURL)
74
4x
    _, span := observability.TraceDatabaseFunction(context.Background(), "InitDB",
75
4x
        attribute.String("db.url", databaseURL),
76
4x
        attribute.String("db.name", dbName),
77
4x
        attribute.String("db.system", "postgresql"),
78
4x
        attribute.Bool("migrations.enabled", true),
79
4x
    )
80
4x
    defer observability.FinishSpan(span, &err)
81
4x
    config := DefaultDatabaseConfig()
82
4x
    config.URL = databaseURL
83
4x
    return dm.InitDBWithConfig(config)
84
4x
}
85

86
// InitDBWithConfig initializes and returns a database connection with migrations and custom config
87
4x
func (dm *Manager) InitDBWithConfig(config config.DatabaseConfig) (result0 *sql.DB, err error) {
88
4x
    dbName := extractDatabaseName(config.URL)
89
4x
    _, span := observability.TraceDatabaseFunction(context.Background(), "InitDBWithConfig",
90
4x
        attribute.String("db.url", config.URL),
91
4x
        attribute.String("db.name", dbName),
92
4x
        attribute.String("db.system", "postgresql"),
93
4x
        attribute.Bool("migrations.enabled", true),
94
4x
        attribute.Int("db.max_open_conns", config.MaxOpenConns),
95
4x
        attribute.Int("db.max_idle_conns", config.MaxIdleConns),
96
4x
        attribute.String("db.conn_max_lifetime", config.ConnMaxLifetime.String()),
97
4x
    )
98
4x
    defer observability.FinishSpan(span, &err)
99
4x
    db, err := dm.InitDBWithoutMigrations(config)
100
4x
    if err != nil {
101
1x
        return nil, err
102
1x
    }
103

104
2x
    if err := dm.RunMigrations(db); err != nil {
105
        return nil, err
106
    }
107

108
2x
    return db, nil
109
}
110

111
// extractDatabaseName extracts the database name from a PostgreSQL connection string
112
17x
func extractDatabaseName(databaseURL string) string {
113
17x
    // Try to parse as URL first
114
17x
    if u, err := url.Parse(databaseURL); err == nil && u.Path != "" {
115
15x
        // Remove leading slash and return the database name
116
15x
        dbName := strings.TrimPrefix(u.Path, "/")
117
15x
        if dbName != "" {
118
14x
            return dbName
119
14x
        }
120
    }
121

122
    // Fallback: try to extract from connection string format
123
    // postgres://user:pass@host:port/dbname?sslmode=disable
124
3x
    if strings.Contains(databaseURL, "/") {
125
2x
        parts := strings.Split(databaseURL, "/")
126
2x
        if len(parts) > 1 {
127
2x
            // Get the last part and remove query parameters
128
2x
            dbPart := parts[len(parts)-1]
129
2x
            if idx := strings.Index(dbPart, "?"); idx != -1 {
130
1x
                return dbPart[:idx]
131
1x
            }
132
1x
            return dbPart
133
        }
134
    }
135

136
    // Default fallback
137
1x
    return "quiz_db"
138
}
139

140
// InitDBWithoutMigrations initializes and returns a database connection without running migrations
141
11x
func (dm *Manager) InitDBWithoutMigrations(config config.DatabaseConfig) (result0 *sql.DB, err error) {
142
11x
    // Extract database name for OpenTelemetry tracing
143
11x
    ctx, span := observability.TraceDatabaseFunction(context.Background(), "InitDBWithoutMigrations",
144
11x
        attribute.String("database.url", config.URL),
145
11x
    )
146
11x
    defer observability.FinishSpan(span, &err)
147
11x

148
11x
    // Register OpenTelemetry SQL driver once per process and reuse the name
149
11x
    otelDriverOnce.Do(func() {
150
1x
        otelDriverNameCache, otelDriverErr = otelsql.Register("postgres",
151
1x
            otelsql.WithDatabaseName(extractDatabaseName(config.URL)),
152
1x
            otelsql.TraceQueryWithArgs(),
153
1x
            otelsql.WithSystem(semconv.DBSystemPostgreSQL),
154
1x
            otelsql.TraceRowsAffected(),
155
1x
        )
156
1x
    })
157
11x
    if otelDriverErr != nil {
158
        return nil, contextutils.WrapError(otelDriverErr, "failed to register otelsql driver")
159
    }
160

161
    // Connect to database using the instrumented driver
162
11x
    db, err := sql.Open(otelDriverNameCache, config.URL)
163
11x
    if err != nil {
164
        return nil, contextutils.WrapError(err, "failed to open database connection")
165
    }
166

167
    // Set connection pool settings
168
11x
    db.SetMaxOpenConns(config.MaxOpenConns)
169
11x
    db.SetMaxIdleConns(config.MaxIdleConns)
170
11x
    db.SetConnMaxLifetime(config.ConnMaxLifetime)
171
11x

172
11x
    // Test the connection
173
11x
    if err := db.Ping(); err != nil {
174
1x
        if closeErr := db.Close(); closeErr != nil {
175
            dm.logger.Error(ctx, "Failed to close database connection after ping failure", closeErr)
176
        }
177
1x
        return nil, contextutils.WrapError(err, "failed to ping database")
178
    }
179

180
10x
    dm.logger.Info(ctx, "Database connection established without migrations", map[string]interface{}{
181
10x
        "max_open_conns":    config.MaxOpenConns,
182
10x
        "max_idle_conns":    config.MaxIdleConns,
183
10x
        "conn_max_lifetime": config.ConnMaxLifetime,
184
10x
    })
185
10x

186
10x
    return db, nil
187
}
188

189
// RunMigrations executes the application SQL schema and any pending migrations
190
4x
func (dm *Manager) RunMigrations(db *sql.DB) (err error) {
191
4x
    _, span := observability.TraceDatabaseFunction(context.Background(), "RunMigrations",
192
4x
        attribute.String("db.system", "postgresql"),
193
4x
        attribute.String("migration.type", "application_schema"),
194
4x
    )
195
4x
    defer observability.FinishSpan(span, &err)
196
4x
    dm.logger.Info(context.Background(), "Starting database migrations...")
197
4x

198
4x
    // Run the main application schema first
199
4x
    if err := dm.runApplicationSchema(db); err != nil {
200
        return contextutils.WrapError(err, "failed to run application schema")
201
    }
202
4x
    dm.logger.Info(context.Background(), "Application schema applied successfully")
203
4x

204
4x
    // Run golang-migrate migrations if directory exists
205
4x
    if err := dm.runGolangMigrate(); err != nil {
206
        return contextutils.WrapError(err, "failed to run golang-migrate migrations")
207
    }
208

209
4x
    dm.logger.Info(context.Background(), "Database migrations completed successfully")
210
4x
    return nil
211
}
212

213
// runGolangMigrate runs migrations using golang-migrate from migrations
214
8x
func (dm *Manager) runGolangMigrate() (err error) {
215
8x
    migrationsPath, err := dm.GetMigrationsPath()
216
8x
    if err != nil {
217
1x
        dm.logger.Error(context.Background(), "Could not find migrations path", err)
218
1x
        return err // HARD FAIL if migrations path is not set
219
1x
    }
220

221
7x
    _, span := observability.TraceDatabaseFunction(context.Background(), "runGolangMigrate",
222
7x
        attribute.String("db.system", "postgresql"),
223
7x
        attribute.String("migration.type", "golang_migrate"),
224
7x
        attribute.String("migration.path", migrationsPath),
225
7x
    )
226
7x
    defer observability.FinishSpan(span, &err)
227
7x

228
7x
    if migrationsPath == "" {
229
        err = errors.New("no golang-migrate migrations directory found")
230
        dm.logger.Error(context.Background(), "No golang-migrate migrations directory found, hard fail!", err)
231
        return err // HARD FAIL
232
    }
233

234
    // Check if migrations directory exists and has migration files
235
7x
    if _, statErr := os.Stat(migrationsPath); os.IsNotExist(statErr) {
236
        dm.logger.Error(context.Background(), "Migrations directory does not exist", statErr)
237
        err = statErr // HARD FAIL if directory does not exist
238
        return err
239
    }
240

241
    // Check if there are any migration files in the directory
242
7x
    files, err := os.ReadDir(migrationsPath)
243
7x
    if err != nil {
244
        dm.logger.Error(context.Background(), "Could not read migrations directory", err)
245
        return err // HARD FAIL
246
    }
247

248
    // Check if there are any .up.sql files
249
7x
    hasMigrationFiles := false
250
7x
    migrationFileCount := 0
251
7x
    for _, file := range files {
252
462x
        if !file.IsDir() && strings.HasSuffix(file.Name(), ".up.sql") {
253
228x
            hasMigrationFiles = true
254
228x
            migrationFileCount++
255
228x
        }
256
    }
257

258
7x
    span.SetAttributes(attribute.Int("migration.files.count", migrationFileCount))
259
7x

260
7x
    if !hasMigrationFiles {
261
1x
        dm.logger.Info(context.Background(), fmt.Sprintf("No migration files found in %s. Skipping golang-migrate.", migrationsPath))
262
1x
        return nil
263
1x
    }
264

265
6x
    dbURL := os.Getenv("DATABASE_URL")
266
6x
    if dbURL == "" {
267
5x
        dbURL = os.Getenv("TEST_DATABASE_URL")
268
5x
    }
269
6x
    if dbURL == "" {
270
1x
        err = errors.New("database_url or test_database_url must be set for migrations")
271
1x
        return err
272
1x
    }
273

274
    // Use file:// scheme with absolute path for golang-migrate
275
    // Convert to file:// URL format - use absolute path
276
5x
    migrationSourceURL := "file://" + filepath.ToSlash(migrationsPath)
277
5x

278
5x
    // Debug logging
279
5x
    dm.logger.Info(context.Background(), "Migration paths", map[string]interface{}{
280
5x
        "migrations_path": migrationsPath,
281
5x
        "source_url":      migrationSourceURL,
282
5x
        "db_url":          dbURL,
283
5x
    })
284
5x

285
5x
    m, err := migrate.New(
286
5x
        migrationSourceURL,
287
5x
        dbURL,
288
5x
    )
289
5x
    if err != nil {
290
1x
        err = contextutils.WrapError(err, "failed to initialize golang-migrate")
291
1x
        return err
292
1x
    }
293
4x
    defer func() {
294
4x
        if _, closeErr := m.Close(); closeErr != nil {
295
            dm.logger.Error(context.Background(), "Error closing migration", closeErr)
296
        }
297
    }()
298

299
4x
    err = m.Up()
300
4x
    if err != nil && err != migrate.ErrNoChange {
301
        err = contextutils.WrapError(err, "golang-migrate up failed")
302
        return err
303
    }
304
4x
    if err == migrate.ErrNoChange {
305
4x
        dm.logger.Info(context.Background(), "No new golang-migrate migrations to apply.")
306
4x
    } else {
307
        dm.logger.Info(context.Background(), "golang-migrate migrations applied successfully.")
308
    }
309
4x
    return nil
310
}
311

312
// runApplicationSchema executes the main application schema.sql
313
7x
func (dm *Manager) runApplicationSchema(db *sql.DB) (err error) {
314
7x
    schemaPath, err := dm.getSchemaPath()
315
7x
    if err != nil {
316
        err = contextutils.WrapError(err, "failed to find schema file")
317
        return err
318
    }
319

320
7x
    _, span := observability.TraceDatabaseFunction(context.Background(), "runApplicationSchema",
321
7x
        attribute.String("db.system", "postgresql"),
322
7x
        attribute.String("migration.type", "application_schema"),
323
7x
        attribute.String("schema.path", schemaPath),
324
7x
    )
325
7x
    defer observability.FinishSpan(span, &err)
326
7x
    // Get the schema file path relative to the project root
327
7x
    schemaPath, err = dm.getSchemaPath()
328
7x
    if err != nil {
329
        err = contextutils.WrapError(err, "failed to find schema file")
330
        return err
331
    }
332

333
    // Read the schema file
334
7x
    schemaSQL, err := os.ReadFile(schemaPath)
335
7x
    if err != nil {
336
        err = contextutils.WrapError(err, "failed to read schema file")
337
        return err
338
    }
339

340
7x
    span.SetAttributes(attribute.Int("schema.file.size", len(schemaSQL)))
341
7x

342
7x
    // Parse SQL statements more carefully to handle comments and multi-line statements
343
7x
    statements := dm.parseSchemaStatements(string(schemaSQL))
344
7x

345
7x
    span.SetAttributes(attribute.Int("schema.statements.count", len(statements)))
346
7x

347
7x
    // Execute table creation statements first
348
7x
    var indexStatements []string
349
7x
    for _, statement := range statements {
350
741x
        statement = strings.TrimSpace(statement)
351
741x
        if statement == "" {
352
            continue
353
        }
354

355
        // Separate index creation from table creation
356
741x
        if strings.HasPrefix(strings.ToUpper(statement), "CREATE INDEX") {
357
519x
            indexStatements = append(indexStatements, statement)
358
519x
            continue
359
        }
360

361
222x
        _, execErr := db.Exec(statement)
362
222x
        if execErr != nil {
363
1x
            // For backwards compatibility, ignore table exists errors
364
1x
            if !dm.isTableExistsError(execErr) {
365
                err = contextutils.WrapErrorf(execErr, "failed to execute schema statement: %s", statement)
366
                return err
367
            }
368
        }
369
    }
370

371
7x
    span.SetAttributes(attribute.Int("schema.index_statements.count", len(indexStatements)))
372
7x

373
7x
    // Now execute index creation statements
374
7x
    for _, statement := range indexStatements {
375
519x
        _, execErr := db.Exec(statement)
376
519x
        if execErr != nil {
377
3x
            // For backwards compatibility, ignore index exists and column exists errors
378
3x
            if !dm.isTableExistsError(execErr) && !dm.isColumnExistsError(execErr) {
379
                err = contextutils.WrapErrorf(execErr, "failed to execute index statement: %s", statement)
380
                return err
381
            }
382
        }
383
    }
384

385
7x
    return nil
386
}
387

388
// getSchemaPath finds the schema.sql file relative to the project root
389
16x
func (dm *Manager) getSchemaPath() (result0 string, err error) {
390
16x
    _, span := observability.TraceDatabaseFunction(context.Background(), "getSchemaPath",
391
16x
        attribute.String("file.name", "schema.sql"),
392
16x
    )
393
16x
    defer observability.FinishSpan(span, &err)
394
16x
    // Start from the current directory and work up to find schema.sql
395
16x
    currentDir, err := os.Getwd()
396
16x
    if err != nil {
397
        return "", err
398
    }
399

400
16x
    span.SetAttributes(attribute.String("search.start_dir", currentDir))
401
16x

402
16x
    for {
403
52x
        schemaPath := filepath.Join(currentDir, "schema.sql")
404
52x
        if _, statErr := os.Stat(schemaPath); statErr == nil {
405
16x
            span.SetAttributes(attribute.String("schema.found_path", schemaPath))
406
16x
            return schemaPath, nil
407
16x
        }
408

409
        // Move up one directory
410
36x
        parentDir := filepath.Dir(currentDir)
411
36x
        if parentDir == currentDir {
412
            // We've reached the root directory
413
            span.SetAttributes(attribute.String("search.result", "not_found"))
414
            err = contextutils.ErrorWithContextf("schema.sql not found in any parent directory")
415
            return "", err
416
        }
417
36x
        currentDir = parentDir
418
    }
419
}
420

421
// parseSchemaStatements parses SQL statements from a schema file
422
8x
func (dm *Manager) parseSchemaStatements(schemaSQL string) []string {
423
8x
    _, span := observability.TraceDatabaseFunction(context.Background(), "parseSchemaStatements",
424
8x
        attribute.Int("input.length", len(schemaSQL)),
425
8x
    )
426
8x
    defer span.End()
427
8x

428
8x
    // Remove comments and normalize whitespace
429
8x
    lines := strings.Split(schemaSQL, "\n")
430
8x
    var cleanedLines []string
431
8x
    inComment := false
432
8x

433
8x
    for _, line := range lines {
434
4080x
        line = strings.TrimSpace(line)
435
4080x

436
4080x
        // Skip empty lines
437
4080x
        if line == "" {
438
10x
            continue
439
        }
440

441
        // Handle multi-line comments
442
4070x
        if strings.HasPrefix(line, "/*") {
443
            inComment = true
444
            continue
445
        }
446
4070x
        if strings.HasSuffix(line, "*/") {
447
            inComment = false
448
            continue
449
        }
450
4070x
        if inComment {
451
            continue
452
        }
453

454
        // Skip single-line comments
455
4070x
        if strings.HasPrefix(line, "--") {
456
564x
            continue
457
        }
458

459
        // Remove inline comments (comments that appear after SQL code)
460
3506x
        if commentIndex := strings.Index(line, "--"); commentIndex != -1 {
461
            line = strings.TrimSpace(line[:commentIndex])
462
        }
463

464
3506x
        cleanedLines = append(cleanedLines, line)
465
    }
466

467
    // Join lines and split by semicolon
468
8x
    cleanedSQL := strings.Join(cleanedLines, " ")
469
8x
    statements := strings.Split(cleanedSQL, ";")
470
8x

471
8x
    var result []string
472
8x
    for _, stmt := range statements {
473
898x
        stmt = strings.TrimSpace(stmt)
474
898x
        if stmt != "" {
475
888x
            result = append(result, stmt)
476
888x
        }
477
    }
478

479
8x
    span.SetAttributes(attribute.Int("statements.parsed", len(result)))
480
8x
    return result
481
}
482

483
// isTableExistsError checks if the error is due to a table already existing
484
5x
func (dm *Manager) isTableExistsError(err error) bool {
485
5x
    _, span := observability.TraceDatabaseFunction(context.Background(), "isTableExistsError")
486
5x
    defer span.End()
487
5x
    // Check for the sentinel error first
488
5x
    if errors.Is(err, ErrTableAlreadyExists) {
489
        return true
490
    }
491
    // Fallback to string matching for backwards compatibility
492
5x
    return strings.Contains(err.Error(), "already exists")
493
}
494

495
// isColumnExistsError checks if the error is due to a column not existing (for index creation)
496
4x
func (dm *Manager) isColumnExistsError(err error) bool {
497
4x
    _, span := observability.TraceDatabaseFunction(context.Background(), "isColumnExistsError")
498
4x
    defer span.End()
499
4x
    return strings.Contains(err.Error(), "column") && strings.Contains(err.Error(), "does not exist")
500
4x
}
501

502
// GetMigrationsPath returns the path to the migrations directory
503
9x
func (dm *Manager) GetMigrationsPath() (result0 string, err error) {
504
9x
    _, span := observability.TraceDatabaseFunction(context.Background(), "GetMigrationsPath",
505
9x
        attribute.String("migration.dir.name", "migrations"),
506
9x
    )
507
9x
    defer observability.FinishSpan(span, &err)
508
9x
    // Start from the current directory and work up to find migrations directory
509
9x
    currentDir, err := os.Getwd()
510
9x
    if err != nil {
511
        return "", err
512
    }
513

514
9x
    span.SetAttributes(attribute.String("search.start_dir", currentDir))
515
9x

516
9x
    for {
517
31x
        migrationsPath := filepath.Join(currentDir, "migrations")
518
31x
        if _, statErr := os.Stat(migrationsPath); statErr == nil {
519
8x
            span.SetAttributes(attribute.String("migration.found_path", migrationsPath))
520
8x
            return migrationsPath, nil
521
8x
        }
522

523
        // Move up one directory
524
23x
        parentDir := filepath.Dir(currentDir)
525
23x
        if parentDir == currentDir {
526
1x
            // We've reached the root directory
527
1x
            span.SetAttributes(attribute.String("search.result", "not_found"))
528
1x
            err = contextutils.ErrorWithContextf("migrations directory not found in any parent directory")
529
1x
            return "", err
530
1x
        }
531
22x
        currentDir = parentDir
532
    }
533
}
534


			
quizapp internal di
95.0%
Statements
115/121
container.go
95.0%
115/121
quizapp internal di container.go
95.0%
Statements
115/121
1
// Package di provides dependency injection container for managing service lifecycle and dependencies.
2
package di
3

4
import (
5
    "context"
6
    "database/sql"
7
    "sync"
8

9
    "quizapp/internal/config"
10
    "quizapp/internal/database"
11
    "quizapp/internal/observability"
12
    "quizapp/internal/services"
13
    contextutils "quizapp/internal/utils"
14
)
15

16
// ServiceContainerInterface defines the interface for service containers
17
type ServiceContainerInterface interface {
18
    GetService(name string) (interface{}, error)
19
    GetUserService() (services.UserServiceInterface, error)
20
    GetQuestionService() (services.QuestionServiceInterface, error)
21
    GetLearningService() (services.LearningServiceInterface, error)
22
    GetAIService() (services.AIServiceInterface, error)
23
    GetWorkerService() (services.WorkerServiceInterface, error)
24
    GetDailyQuestionService() (services.DailyQuestionServiceInterface, error)
25
    GetStoryService() (services.StoryServiceInterface, error)
26
    GetOAuthService() (*services.OAuthService, error)
27
    GetGenerationHintService() (services.GenerationHintServiceInterface, error)
28
    GetConversationService() (services.ConversationServiceInterface, error)
29
    GetEmailService() (services.EmailServiceInterface, error)
30
    GetTranslationService() (services.TranslationServiceInterface, error)
31
    GetSnippetsService() (services.SnippetsServiceInterface, error)
32
    GetUsageStatsService() (services.UsageStatsServiceInterface, error)
33
    GetWordOfTheDayService() (services.WordOfTheDayServiceInterface, error)
34
    GetAuthAPIKeyService() (services.AuthAPIKeyServiceInterface, error)
35
    GetTranslationPracticeService() (services.TranslationPracticeServiceInterface, error)
36
    GetDatabase() *sql.DB
37
    GetConfig() *config.Config
38
    GetLogger() *observability.Logger
39
    Initialize(ctx context.Context) error
40
    Shutdown(ctx context.Context) error
41
    EnsureAdminUser(ctx context.Context) error
42
}
43

44
// ServiceContainer manages all service dependencies and lifecycle
45
type ServiceContainer struct {
46
    cfg           *config.Config
47
    logger        *observability.Logger
48
    dbManager     *database.Manager
49
    db            *sql.DB
50
    services      map[string]interface{}
51
    mu            sync.RWMutex
52
    shutdownFuncs []func(context.Context) error
53
}
54

55
// NewServiceContainer creates a new dependency injection container
56
10x
func NewServiceContainer(cfg *config.Config, logger *observability.Logger) *ServiceContainer {
57
10x
    return &ServiceContainer{
58
10x
        cfg:      cfg,
59
10x
        logger:   logger,
60
10x
        services: make(map[string]interface{}),
61
10x
    }
62
10x
}
63

64
// Initialize sets up all services and their dependencies
65
8x
func (sc *ServiceContainer) Initialize(ctx context.Context) error {
66
8x
    sc.mu.Lock()
67
8x
    defer sc.mu.Unlock()
68
8x

69
8x
    // Initialize database
70
8x
    sc.dbManager = database.NewManager(sc.logger)
71
8x
    db, err := sc.dbManager.InitDBWithConfig(sc.cfg.Database)
72
8x
    if err != nil {
73
1x
        return contextutils.WrapErrorf(err, "failed to initialize database")
74
1x
    }
75
7x
    sc.db = db
76
7x
    sc.shutdownFuncs = append(sc.shutdownFuncs, func(_ context.Context) error {
77
4x
        return db.Close()
78
4x
    })
79

80
    // Initialize core services
81
7x
    sc.initializeServices(ctx)
82
7x

83
7x
    // Startup lifecycle services
84
7x
    if err := sc.startupServices(ctx); err != nil {
85
        // Cleanup on failure
86
        _ = sc.cleanup(ctx)
87
        return contextutils.WrapErrorf(err, "failed to startup services")
88
    }
89

90
7x
    return nil
91
}
92

93
// GetService retrieves a service by name with type assertion
94
58x
func (sc *ServiceContainer) GetService(name string) (interface{}, error) {
95
58x
    sc.mu.RLock()
96
58x
    defer sc.mu.RUnlock()
97
58x

98
58x
    service, exists := sc.services[name]
99
58x
    if !exists {
100
3x
        return nil, contextutils.ErrorWithContextf("service %s not found", name)
101
3x
    }
102
55x
    return service, nil
103
}
104

105
// GetServiceAs performs type-safe service retrieval
106
54x
func GetServiceAs[T any](sc *ServiceContainer, name string) (T, error) {
107
54x
    var zero T
108
54x
    service, err := sc.GetService(name)
109
54x
    if err != nil {
110
2x
        return zero, err
111
2x
    }
112

113
52x
    typed, ok := service.(T)
114
52x
    if !ok {
115
1x
        return zero, contextutils.ErrorWithContextf("service %s is not of expected type %T", name, zero)
116
1x
    }
117
51x
    return typed, nil
118
}
119

120
// GetUserService returns the user service
121
19x
func (sc *ServiceContainer) GetUserService() (services.UserServiceInterface, error) {
122
19x
    return GetServiceAs[services.UserServiceInterface](sc, "user")
123
19x
}
124

125
// GetQuestionService returns the question service
126
13x
func (sc *ServiceContainer) GetQuestionService() (services.QuestionServiceInterface, error) {
127
13x
    return GetServiceAs[services.QuestionServiceInterface](sc, "question")
128
13x
}
129

130
// GetLearningService returns the learning service
131
3x
func (sc *ServiceContainer) GetLearningService() (services.LearningServiceInterface, error) {
132
3x
    return GetServiceAs[services.LearningServiceInterface](sc, "learning")
133
3x
}
134

135
// GetAIService returns the AI service
136
2x
func (sc *ServiceContainer) GetAIService() (services.AIServiceInterface, error) {
137
2x
    return GetServiceAs[services.AIServiceInterface](sc, "ai")
138
2x
}
139

140
// GetWorkerService returns the worker service
141
2x
func (sc *ServiceContainer) GetWorkerService() (services.WorkerServiceInterface, error) {
142
2x
    return GetServiceAs[services.WorkerServiceInterface](sc, "worker")
143
2x
}
144

145
// GetDailyQuestionService returns the daily question service
146
2x
func (sc *ServiceContainer) GetDailyQuestionService() (services.DailyQuestionServiceInterface, error) {
147
2x
    return GetServiceAs[services.DailyQuestionServiceInterface](sc, "daily_question")
148
2x
}
149

150
// GetStoryService returns the story service
151
1x
func (sc *ServiceContainer) GetStoryService() (services.StoryServiceInterface, error) {
152
1x
    return GetServiceAs[services.StoryServiceInterface](sc, "story")
153
1x
}
154

155
// GetOAuthService returns the OAuth service
156
2x
func (sc *ServiceContainer) GetOAuthService() (*services.OAuthService, error) {
157
2x
    service, err := sc.GetService("oauth")
158
2x
    if err != nil {
159
        return nil, err
160
    }
161
2x
    oauthService, ok := service.(*services.OAuthService)
162
2x
    if !ok {
163
        return nil, contextutils.ErrorWithContextf("oauth service has incorrect type")
164
    }
165
2x
    return oauthService, nil
166
}
167

168
// GetGenerationHintService returns the generation hint service
169
2x
func (sc *ServiceContainer) GetGenerationHintService() (services.GenerationHintServiceInterface, error) {
170
2x
    return GetServiceAs[services.GenerationHintServiceInterface](sc, "generation_hint")
171
2x
}
172

173
// GetConversationService returns the conversation service
174
1x
func (sc *ServiceContainer) GetConversationService() (services.ConversationServiceInterface, error) {
175
1x
    return GetServiceAs[services.ConversationServiceInterface](sc, "conversation")
176
1x
}
177

178
// GetEmailService returns the email service
179
2x
func (sc *ServiceContainer) GetEmailService() (services.EmailServiceInterface, error) {
180
2x
    return GetServiceAs[services.EmailServiceInterface](sc, "email")
181
2x
}
182

183
// GetTranslationService returns the translation service
184
1x
func (sc *ServiceContainer) GetTranslationService() (services.TranslationServiceInterface, error) {
185
1x
    return GetServiceAs[services.TranslationServiceInterface](sc, "translation")
186
1x
}
187

188
// GetSnippetsService returns the snippets service
189
1x
func (sc *ServiceContainer) GetSnippetsService() (services.SnippetsServiceInterface, error) {
190
1x
    return GetServiceAs[services.SnippetsServiceInterface](sc, "snippets")
191
1x
}
192

193
// GetUsageStatsService returns the usage stats service
194
1x
func (sc *ServiceContainer) GetUsageStatsService() (services.UsageStatsServiceInterface, error) {
195
1x
    return GetServiceAs[services.UsageStatsServiceInterface](sc, "usage_stats")
196
1x
}
197

198
// GetWordOfTheDayService returns the word of the day service
199
1x
func (sc *ServiceContainer) GetWordOfTheDayService() (services.WordOfTheDayServiceInterface, error) {
200
1x
    return GetServiceAs[services.WordOfTheDayServiceInterface](sc, "word_of_the_day")
201
1x
}
202

203
// GetAuthAPIKeyService returns the auth API key service
204
1x
func (sc *ServiceContainer) GetAuthAPIKeyService() (services.AuthAPIKeyServiceInterface, error) {
205
1x
    return GetServiceAs[services.AuthAPIKeyServiceInterface](sc, "auth_api_key")
206
1x
}
207

208
// GetTranslationPracticeService returns the translation practice service
209
func (sc *ServiceContainer) GetTranslationPracticeService() (services.TranslationPracticeServiceInterface, error) {
210
    return GetServiceAs[services.TranslationPracticeServiceInterface](sc, "translation_practice")
211
}
212

213
// GetDatabase returns the database instance
214
14x
func (sc *ServiceContainer) GetDatabase() *sql.DB {
215
14x
    return sc.db
216
14x
}
217

218
// GetConfig returns the configuration
219
13x
func (sc *ServiceContainer) GetConfig() *config.Config {
220
13x
    return sc.cfg
221
13x
}
222

223
// GetLogger returns the logger
224
13x
func (sc *ServiceContainer) GetLogger() *observability.Logger {
225
13x
    return sc.logger
226
13x
}
227

228
// Shutdown gracefully shuts down all services
229
4x
func (sc *ServiceContainer) Shutdown(ctx context.Context) error {
230
4x
    sc.mu.Lock()
231
4x
    defer sc.mu.Unlock()
232
4x

233
4x
    return sc.cleanup(ctx)
234
4x
}
235

236
// startupServices starts all services that implement the Lifecycle interface
237
8x
func (sc *ServiceContainer) startupServices(ctx context.Context) error {
238
8x
    // Check each service to see if it implements Lifecycle interface
239
8x
    for name, service := range sc.services {
240
127x
        if lifecycleService, ok := service.(interface{ Startup(context.Context) error }); ok {
241
1x
            sc.logger.Info(ctx, "Starting service", map[string]interface{}{"service": name})
242
1x
            if err := lifecycleService.Startup(ctx); err != nil {
243
1x
                return contextutils.WrapErrorf(err, "failed to startup service %s", name)
244
1x
            }
245
            sc.logger.Info(ctx, "Service started successfully", map[string]interface{}{"service": name})
246
        }
247
    }
248
7x
    return nil
249
}
250

251
// cleanup handles shutdown of all services
252
5x
func (sc *ServiceContainer) cleanup(ctx context.Context) error {
253
5x
    var errors []error
254
5x

255
5x
    // Shutdown lifecycle services first (in reverse order)
256
5x
    for name := range sc.services {
257
73x
        if lifecycleService, ok := sc.services[name].(interface{ Shutdown(context.Context) error }); ok {
258
5x
            sc.logger.Info(ctx, "Shutting down service", map[string]interface{}{"service": name})
259
5x
            if err := lifecycleService.Shutdown(ctx); err != nil {
260
1x
                sc.logger.Error(ctx, "Failed to shutdown service", err, map[string]interface{}{"service": name})
261
1x
                errors = append(errors, contextutils.WrapErrorf(err, "service %s shutdown failed", name))
262
1x
            } else {
263
4x
                sc.logger.Info(ctx, "Service shutdown successfully", map[string]interface{}{"service": name})
264
4x
            }
265
        }
266
    }
267

268
    // Shutdown services in reverse order of initialization
269
5x
    for i := len(sc.shutdownFuncs) - 1; i >= 0; i-- {
270
9x
        if err := sc.shutdownFuncs[i](ctx); err != nil {
271
1x
            errors = append(errors, err)
272
1x
        }
273
    }
274

275
5x
    if len(errors) > 0 {
276
1x
        return contextutils.ErrorWithContextf("shutdown errors: %v", errors)
277
1x
    }
278
4x
    return nil
279
}
280

281
// initializeServices sets up all service dependencies
282
7x
func (sc *ServiceContainer) initializeServices(_ context.Context) {
283
7x
    // Core services that don't depend on other services
284
7x
    userService := services.NewUserServiceWithLogger(sc.db, sc.cfg, sc.logger)
285
7x
    sc.services["user"] = userService
286
7x

287
7x
    // Learning service depends on user service
288
7x
    learningService := services.NewLearningServiceWithLogger(sc.db, sc.cfg, sc.logger)
289
7x
    sc.services["learning"] = learningService
290
7x

291
7x
    // Question service depends on learning service
292
7x
    questionService := services.NewQuestionServiceWithLogger(sc.db, learningService, sc.cfg, sc.logger)
293
7x
    sc.services["question"] = questionService
294
7x

295
7x
    // Daily question service depends on question and learning services
296
7x
    dailyQuestionService := services.NewDailyQuestionService(sc.db, sc.logger, questionService, learningService)
297
7x
    sc.services["daily_question"] = dailyQuestionService
298
7x

299
7x
    // Story service
300
7x
    storyService := services.NewStoryService(sc.db, sc.cfg, sc.logger)
301
7x
    sc.services["story"] = storyService
302
7x

303
7x
    // Worker service
304
7x
    workerService := services.NewWorkerServiceWithLogger(sc.db, sc.logger)
305
7x
    sc.services["worker"] = workerService
306
7x

307
7x
    // Generation hint service
308
7x
    generationHintService := services.NewGenerationHintService(sc.db, sc.logger)
309
7x
    sc.services["generation_hint"] = generationHintService
310
7x

311
7x
    // OAuth service
312
7x
    oauthService := services.NewOAuthServiceWithLogger(sc.cfg, sc.logger)
313
7x
    sc.services["oauth"] = oauthService
314
7x

315
7x
    // Conversation service
316
7x
    conversationService := services.NewConversationService(sc.db)
317
7x
    sc.services["conversation"] = conversationService
318
7x

319
7x
    // Email service (use concrete implementation with DB to satisfy EmailServiceInterface)
320
7x
    emailService := services.NewEmailServiceWithDB(sc.cfg, sc.logger, sc.db)
321
7x
    sc.services["email"] = emailService
322
7x

323
7x
    // Usage stats service
324
7x
    usageStatsService := services.NewUsageStatsService(sc.cfg, sc.db, sc.logger)
325
7x
    sc.services["usage_stats"] = usageStatsService
326
7x

327
7x
    // AI service (depends on usage stats service)
328
7x
    aiService := services.NewAIService(sc.cfg, sc.logger, usageStatsService)
329
7x
    sc.services["ai"] = aiService
330
7x

331
7x
    // Translation cache repository
332
7x
    translationCacheRepo := services.NewTranslationCacheRepository(sc.db, sc.logger)
333
7x
    sc.services["translation_cache"] = translationCacheRepo
334
7x

335
7x
    // Translation service (depends on usage stats service and translation cache repository)
336
7x
    translationService := services.NewTranslationService(sc.cfg, usageStatsService, translationCacheRepo, sc.logger)
337
7x
    sc.services["translation"] = translationService
338
7x

339
7x
    // Initialize snippets service
340
7x
    snippetsService := services.NewSnippetsService(sc.db, sc.cfg, sc.logger)
341
7x
    sc.services["snippets"] = snippetsService
342
7x

343
7x
    // Initialize word of the day service
344
7x
    wordOfTheDayService := services.NewWordOfTheDayService(sc.db, sc.logger)
345
7x
    sc.services["word_of_the_day"] = wordOfTheDayService
346
7x

347
7x
    // Initialize auth API key service
348
7x
    authAPIKeyService := services.NewAuthAPIKeyService(sc.db, sc.logger)
349
7x
    sc.services["auth_api_key"] = authAPIKeyService
350
7x

351
7x
    // Initialize translation practice service
352
7x
    translationPracticeService := services.NewTranslationPracticeService(
353
7x
        sc.db,
354
7x
        storyService,
355
7x
        questionService,
356
7x
        sc.cfg,
357
7x
        sc.logger,
358
7x
    )
359
7x
    sc.services["translation_practice"] = translationPracticeService
360
7x

361
7x
    // Register shutdown functions
362
7x
    sc.shutdownFuncs = append(sc.shutdownFuncs,
363
7x
        func(_ context.Context) error { return nil }, // placeholder for future service shutdowns
364
    )
365
}
366

367
// EnsureAdminUser creates the admin user if it doesn't exist
368
4x
func (sc *ServiceContainer) EnsureAdminUser(ctx context.Context) error {
369
4x
    userService, err := sc.GetUserService()
370
4x
    if err != nil {
371
1x
        return contextutils.WrapErrorf(err, "failed to get user service")
372
1x
    }
373

374
3x
    return userService.EnsureAdminUserExists(ctx, sc.cfg.Server.AdminUsername, sc.cfg.Server.AdminPassword)
375
}
376


			
quizapp internal handlers
49.2%
Statements
2735/5564
admin_handler.go
33.5%
283/846
ai_fix_utils.go
59.4%
38/64
ai_handler.go
0.4%
1/233
auth_api_key_handler.go
1.1%
1/87
auth_handler.go
73.2%
213/291
authz.go
86.4%
19/22
convert.go
85.1%
298/350
daily_question_handler.go
58.1%
172/296
error_utils.go
64.4%
29/45
feedback_handler.go
55.0%
127/231
pagination.go
100.0%
20/20
quiz_handler.go
52.2%
291/558
route_listing.go
31.4%
11/35
router_factory.go
96.2%
275/286
session.go
61.5%
16/26
settings_handler.go
69.5%
189/272
snippets_handler.go
10.2%
34/332
story_handler.go
47.9%
161/336
test_mocks.go
27.3%
15/55
translation_handler.go
2.4%
1/42
translation_practice_handler.go
45.6%
72/158
user_admin_handler.go
44.6%
181/406
verb_conjugation_handler.go
67.0%
61/91
word_of_the_day_handler.go
59.4%
63/106
worker_admin_handler.go
43.6%
164/376
quizapp internal handlers worker_admin_handler.go
33.5%
Statements
283/846
1
// Package handlers provides HTTP request handlers for the quiz application API.
2
package handlers
3

4
import (
5
    "context"
6
    "database/sql"
7
    "encoding/json"
8
    "errors"
9
    "html/template"
10
    "math"
11
    "net/http"
12
    "strconv"
13
    "strings"
14
    "time"
15

16
    "quizapp/internal/config"
17
    "quizapp/internal/models"
18
    "quizapp/internal/observability"
19
    "quizapp/internal/services"
20
    contextutils "quizapp/internal/utils"
21

22
    "github.com/gin-gonic/gin"
23
    "go.opentelemetry.io/otel/attribute"
24
)
25

26
// AdminHandler handles administrative HTTP requests and dashboard functionality
27
type AdminHandler struct {
28
    userService     services.UserServiceInterface
29
    questionService services.QuestionServiceInterface
30
    aiService       services.AIServiceInterface
31
    config          *config.Config
32
    templates       *template.Template
33
    learningService services.LearningServiceInterface
34
    workerService   services.WorkerServiceInterface
35
    logger          *observability.Logger
36
    storyService    services.StoryServiceInterface
37
    usageStatsSvc   services.UsageStatsServiceInterface
38
}
39

40
// NewAdminHandlerWithLogger creates a new AdminHandler with the provided services and logger.
41
14x
func NewAdminHandlerWithLogger(userService services.UserServiceInterface, questionService services.QuestionServiceInterface, aiService services.AIServiceInterface, cfg *config.Config, learningService services.LearningServiceInterface, workerService services.WorkerServiceInterface, logger *observability.Logger, usageStatsSvc services.UsageStatsServiceInterface) *AdminHandler {
42
14x
    return &AdminHandler{
43
14x
        userService:     userService,
44
14x
        questionService: questionService,
45
14x
        aiService:       aiService,
46
14x
        config:          cfg,
47
14x
        templates:       nil,
48
14x
        learningService: learningService,
49
14x
        workerService:   workerService,
50
14x
        logger:          logger,
51
14x
        usageStatsSvc:   usageStatsSvc,
52
14x
    }
53
14x
}
54

55
// buildInvalidQuestionMap creates a map representation of an invalid question for admin display
56
func buildInvalidQuestionMap(q *services.QuestionWithStats, conversionErr error) map[string]interface{} {
57
    invalidQuestion := map[string]interface{}{
58
        "id": func() *int64 {
59
            if q != nil && q.Question != nil {
60
                id := int64(q.ID)
61
                return &id
62
            }
63
            return nil
64
        }(),
65
        "language": func() *string {
66
            if q != nil && q.Question != nil {
67
                return &q.Language
68
            }
69
            return nil
70
        }(),
71
        "level": func() *string {
72
            if q != nil && q.Question != nil {
73
                return &q.Level
74
            }
75
            return nil
76
        }(),
77
        "type": func() *string {
78
            if q != nil && q.Question != nil {
79
                t := string(q.Type)
80
                return &t
81
            }
82
            return nil
83
        }(),
84
        "status": func() *string {
85
            if q != nil && q.Question != nil && q.Status != "" {
86
                s := string(q.Status)
87
                return &s
88
            }
89
            return nil
90
        }(),
91
        "conversion_error": conversionErr.Error(),
92
        "is_invalid":       true,
93
    }
94
    // Add created_at if available
95
    if q != nil && q.Question != nil && !q.CreatedAt.IsZero() {
96
        createdAt := q.CreatedAt.Format(time.RFC3339)
97
        invalidQuestion["created_at"] = &createdAt
98
    }
99
    // Add stats if available
100
    if q != nil {
101
        invalidQuestion["correct_count"] = q.CorrectCount
102
        invalidQuestion["incorrect_count"] = q.IncorrectCount
103
        invalidQuestion["total_responses"] = q.TotalResponses
104
        invalidQuestion["user_count"] = q.UserCount
105
        if q.Reporters != "" {
106
            invalidQuestion["reporters"] = q.Reporters
107
        }
108
        if q.ReportReasons != "" {
109
            invalidQuestion["report_reasons"] = q.ReportReasons
110
        }
111
    }
112
    return invalidQuestion
113
}
114

115
// GetBackendAdminData returns the backend administration data as JSON
116
func (h *AdminHandler) GetBackendAdminData(c *gin.Context) {
117
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_backend_admin_data")
118
    defer observability.FinishSpan(span, nil)
119

120
    // Get all users for aggregate statistics
121
    users, err := h.userService.GetAllUsers(ctx)
122
    if err != nil {
123
        span.SetAttributes(attribute.String("error", err.Error()))
124
        HandleAppError(c, contextutils.WrapError(err, "failed to get users"))
125
        return
126
    }
127

128
    // Calculate aggregate user statistics
129
    userStats := calculateUserAggregateStats(ctx, users, h.learningService, h.logger)
130

131
    // Get question statistics
132
    questionStats, err := h.questionService.GetDetailedQuestionStats(ctx)
133
    if err != nil {
134
        h.logger.Warn(ctx, "Failed to get question stats", map[string]interface{}{"error": err.Error()})
135
        questionStats = make(map[string]interface{})
136
    }
137

138
    // Get worker health if available
139
    var workerHealth map[string]interface{}
140
    if h.workerService != nil {
141
        workerHealth, err = h.workerService.GetWorkerHealth(ctx)
142
        if err != nil {
143
            h.logger.Warn(ctx, "Failed to get worker health", map[string]interface{}{"error": err.Error()})
144
            workerHealth = map[string]interface{}{
145
                "error": "Failed to get worker health",
146
            }
147
        }
148
    }
149

150
    // Get AI concurrency stats
151
    aiStatsStruct := h.aiService.GetConcurrencyStats()
152
    aiConcurrencyStats := map[string]interface{}{
153
        "active_requests":   aiStatsStruct.ActiveRequests,
154
        "max_concurrent":    aiStatsStruct.MaxConcurrent,
155
        "queued_requests":   aiStatsStruct.QueuedRequests,
156
        "total_requests":    aiStatsStruct.TotalRequests,
157
        "user_active_count": aiStatsStruct.UserActiveCount,
158
        "max_per_user":      aiStatsStruct.MaxPerUser,
159
    }
160

161
    data := gin.H{
162
        "user_stats":           userStats,
163
        "question_stats":       questionStats,
164
        "worker_health":        workerHealth,
165
        "ai_concurrency_stats": aiConcurrencyStats,
166
        "worker_port":          h.config.Server.WorkerPort,
167
        "worker_base_url":      h.config.Server.WorkerBaseURL,
168
    }
169

170
    c.JSON(http.StatusOK, data)
171
}
172

173
// GetBackendAdminPage renders the backend administration dashboard
174
1x
func (h *AdminHandler) GetBackendAdminPage(c *gin.Context) {
175
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_backend_admin_page")
176
1x
    defer observability.FinishSpan(span, nil)
177
1x

178
1x
    // Get all users with progress and question stats
179
1x
    users, err := h.userService.GetAllUsers(ctx)
180
1x
    if err != nil {
181
        span.SetAttributes(attribute.String("error", err.Error()))
182
        HandleAppError(c, contextutils.WrapError(err, "failed to get users"))
183
        return
184
    }
185

186
1x
    type UserWithProgress struct {
187
1x
        User               models.User
188
1x
        Progress           *models.UserProgress
189
1x
        QuestionStats      *services.UserQuestionStats
190
1x
        UserQuestionCounts map[string]interface{}
191
1x
    }
192
1x

193
1x
    var usersWithProgress []UserWithProgress
194
1x
    for _, user := range users {
195
1x
        progress, err := h.learningService.GetUserProgress(ctx, user.ID)
196
1x
        if err != nil {
197
            h.logger.Warn(ctx, "Failed to get progress for user", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
198
            progress = &models.UserProgress{
199
                CurrentLevel:   "A1",
200
                TotalQuestions: 0,
201
                CorrectAnswers: 0,
202
                AccuracyRate:   0,
203
            }
204
        }
205

206
1x
        questionStats, err := h.learningService.GetUserQuestionStats(ctx, user.ID)
207
1x
        if err != nil {
208
            h.logger.Warn(ctx, "Failed to get question stats for user", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
209
            questionStats = &services.UserQuestionStats{
210
                UserID:        user.ID,
211
                TotalAnswered: 0,
212
            }
213
        }
214

215
        // Get per-user question counts by type and level
216
1x
        userQuestionCounts := make(map[string]interface{})
217
1x

218
1x
        // Use the available stats from UserQuestionStats
219
1x
        if questionStats != nil {
220
1x
            userQuestionCounts["total_answered"] = questionStats.TotalAnswered
221
1x
            userQuestionCounts["answered_by_type"] = questionStats.AnsweredByType
222
1x
            userQuestionCounts["answered_by_level"] = questionStats.AnsweredByLevel
223
1x
            userQuestionCounts["accuracy_by_type"] = questionStats.AccuracyByType
224
1x
            userQuestionCounts["accuracy_by_level"] = questionStats.AccuracyByLevel
225
1x
            userQuestionCounts["available_by_type"] = questionStats.AvailableByType
226
1x
            userQuestionCounts["available_by_level"] = questionStats.AvailableByLevel
227
1x
        }
228

229
1x
        usersWithProgress = append(usersWithProgress, UserWithProgress{
230
1x
            User:               user,
231
1x
            Progress:           progress,
232
1x
            QuestionStats:      questionStats,
233
1x
            UserQuestionCounts: userQuestionCounts,
234
1x
        })
235
    }
236

237
    // Get question statistics
238
1x
    questionStats, err := h.questionService.GetDetailedQuestionStats(ctx)
239
1x
    if err != nil {
240
        h.logger.Warn(ctx, "Failed to get question stats", map[string]interface{}{"error": err.Error()})
241
        questionStats = make(map[string]interface{})
242
    }
243

244
    // Get worker health if available
245
1x
    var workerHealth map[string]interface{}
246
1x
    if h.workerService != nil {
247
1x
        workerHealth, err = h.workerService.GetWorkerHealth(ctx)
248
1x
        if err != nil {
249
            h.logger.Warn(ctx, "Failed to get worker health", map[string]interface{}{"error": err.Error()})
250
            workerHealth = map[string]interface{}{
251
                "error": "Failed to get worker health",
252
            }
253
        }
254
    }
255

256
    // Get AI concurrency stats
257
1x
    aiStatsStruct := h.aiService.GetConcurrencyStats()
258
1x
    aiConcurrencyStats := map[string]interface{}{
259
1x
        "active_requests":   aiStatsStruct.ActiveRequests,
260
1x
        "max_concurrent":    aiStatsStruct.MaxConcurrent,
261
1x
        "queued_requests":   aiStatsStruct.QueuedRequests,
262
1x
        "total_requests":    aiStatsStruct.TotalRequests,
263
1x
        "user_active_count": aiStatsStruct.UserActiveCount,
264
1x
        "max_per_user":      aiStatsStruct.MaxPerUser,
265
1x
    }
266
1x

267
1x
    data := gin.H{
268
1x
        "Title":              "Backend Administration",
269
1x
        "Users":              usersWithProgress,
270
1x
        "QuestionStats":      questionStats,
271
1x
        "WorkerHealth":       workerHealth,
272
1x
        "AIConcurrencyStats": aiConcurrencyStats,
273
1x
        "IsBackend":          true,
274
1x
        "WorkerPort":         h.config.Server.WorkerPort,
275
1x
        "CurrentPage":        "backend_admin",
276
1x
        "WorkerBaseURL":      h.config.Server.WorkerBaseURL,
277
1x
    }
278
1x

279
1x
    // Try to render template, fallback to JSON if template fails
280
1x
    if h.templates != nil {
281
        // Add no-cache headers
282
        c.Header("Content-Type", "text/html; charset=utf-8")
283
        c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
284
        c.Header("Pragma", "no-cache")
285
        c.Header("Expires", "0")
286

287
        if err := h.templates.ExecuteTemplate(c.Writer, "backend_admin.html", data); err != nil {
288
            h.logger.Error(ctx, "Template execution failed", err, map[string]interface{}{})
289
            HandleAppError(c, contextutils.WrapError(err, "failed to render template"))
290
            return
291
        }
292
1x
    } else {
293
1x
        c.JSON(http.StatusOK, data)
294
1x
    }
295
}
296

297
// UserData represents user information combined with their progress data
298
type UserData struct {
299
    User     models.User
300
    Progress *models.UserProgress
301
}
302

303
// UserDataWithQuestions represents user information with questions and responses
304
type UserDataWithQuestions struct {
305
    User            models.User
306
    Progress        *models.UserProgress
307
    QuestionStats   *services.UserQuestionStats
308
    TotalQuestions  int
309
    TotalResponses  int
310
    RecentQuestions []string
311
    Questions       []*services.QuestionWithStats // Actual question objects with stats
312
}
313

314
// ReportedQuestionsData represents the structure for reported questions page data
315
type ReportedQuestionsData struct {
316
    Users             []UserDataWithQuestions
317
    ReportedQuestions []*services.ReportedQuestionWithUser
318
}
319

320
// ShowDatazPage - Removed: Use frontend admin interface instead
321

322
// MarkQuestionAsFixed marks a reported question as fixed and puts it back in rotation
323
1x
func (h *AdminHandler) MarkQuestionAsFixed(c *gin.Context) {
324
1x
    questionIDStr := c.Param("id")
325
1x
    questionID, err := strconv.Atoi(questionIDStr)
326
1x
    if err != nil {
327
        HandleAppError(c, contextutils.ErrInvalidFormat)
328
        return
329
    }
330

331
1x
    if err := h.questionService.MarkQuestionAsFixed(c.Request.Context(), questionID); err != nil {
332
        h.logger.Error(c.Request.Context(), "Failed to mark question as fixed", err, map[string]interface{}{"question_id": questionID})
333

334
        // Check if the error is due to question not found
335
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
336
            HandleAppError(c, contextutils.ErrQuestionNotFound)
337
            return
338
        }
339

340
        HandleAppError(c, contextutils.WrapError(err, "failed to mark question as fixed"))
341
        return
342
    }
343

344
1x
    c.JSON(http.StatusOK, gin.H{"message": "Question marked as fixed successfully"})
345
}
346

347
// UpdateQuestion updates a question's content, correct answer, and explanation
348
4x
func (h *AdminHandler) UpdateQuestion(c *gin.Context) {
349
4x
    questionIDStr := c.Param("id")
350
4x
    questionID, err := strconv.Atoi(questionIDStr)
351
4x
    if err != nil {
352
        HandleAppError(c, contextutils.ErrInvalidFormat)
353
        return
354
    }
355

356
4x
    var req struct {
357
4x
        Content       map[string]interface{} `json:"content" binding:"required"`
358
4x
        CorrectAnswer int                    `json:"correct_answer" binding:"gte=0,lte=3"`
359
4x
        Explanation   string                 `json:"explanation" binding:"required"`
360
4x
    }
361
4x

362
4x
    if err := c.ShouldBindJSON(&req); err != nil {
363
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
364
1x
            contextutils.ErrorCodeInvalidInput,
365
1x
            contextutils.SeverityWarn,
366
1x
            "Invalid request format",
367
1x
            "",
368
1x
            err,
369
1x
        ))
370
1x
        return
371
1x
    }
372

373
    // Sanitize incoming content to avoid nested `content.content` and duplicated fields.
374
3x
    content := req.Content
375
3x
    for {
376
4x
        if inner, ok := content["content"]; ok {
377
1x
            if innerMap, ok2 := inner.(map[string]interface{}); ok2 {
378
1x
                content = innerMap
379
1x
                continue
380
            }
381
        }
382
3x
        break
383
    }
384

385
    // Remove duplicate top-level keys from the content payload if present.
386
    // Defensive cleanup while migrating to strict OpenAPI validation.
387
3x
    delete(content, "correct_answer")
388
3x
    delete(content, "explanation")
389
3x
    delete(content, "change_reason")
390
3x

391
3x
    // Ensure options is not nil (convert null -> empty slice)
392
3x
    if opts, exists := content["options"]; !exists || opts == nil {
393
        content["options"] = []string{}
394
    }
395

396
    // Validate question content before updating (UpdateQuestion also validates, but this gives better error context)
397
3x
    if err := contextutils.ValidateQuestionContent(content, questionID); err != nil {
398
        h.logger.Warn(c.Request.Context(), "Invalid question content in update request", map[string]interface{}{
399
            "question_id": questionID,
400
            "error":       err.Error(),
401
        })
402
        HandleAppError(c, contextutils.NewAppErrorWithCause(
403
            contextutils.ErrorCodeInvalidInput,
404
            contextutils.SeverityWarn,
405
            "Invalid question content",
406
            "",
407
            err,
408
        ))
409
        return
410
    }
411

412
3x
    if err := h.questionService.UpdateQuestion(c.Request.Context(), questionID, content, req.CorrectAnswer, req.Explanation); err != nil {
413
        h.logger.Error(c.Request.Context(), "Failed to update question", err, map[string]interface{}{"question_id": questionID})
414

415
        // Check if the error is due to question not found
416
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
417
            HandleAppError(c, contextutils.ErrQuestionNotFound)
418
            return
419
        }
420

421
        HandleAppError(c, contextutils.WrapError(err, "failed to update question"))
422
        return
423
    }
424

425
    // If requested, mark the question as fixed and clear reports
426
3x
    if strings.ToLower(c.Query("mark_fixed")) == "true" {
427
2x
        ctx := c.Request.Context()
428
2x
        // Mark as fixed (sets status to active)
429
2x
        if err := h.questionService.MarkQuestionAsFixed(ctx, questionID); err != nil {
430
            h.logger.Error(ctx, "Failed to mark question as fixed after update", err, map[string]interface{}{"question_id": questionID})
431
            HandleAppError(c, contextutils.WrapError(err, "failed to mark question as fixed"))
432
            return
433
        }
434

435
        // Clear question reports
436
2x
        db := h.questionService.DB()
437
2x
        if _, err := db.ExecContext(ctx, `DELETE FROM question_reports WHERE question_id = $1`, questionID); err != nil {
438
            h.logger.Warn(ctx, "Failed to clear question reports", map[string]interface{}{"question_id": questionID, "error": err.Error()})
439
        }
440
    }
441

442
3x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "Question updated successfully"})
443
}
444

445
// FixQuestionWithAI uses AI to suggest fixes for a problematic question
446
3x
func (h *AdminHandler) FixQuestionWithAI(c *gin.Context) {
447
3x
    questionIDStr := c.Param("id")
448
3x
    questionID, err := strconv.Atoi(questionIDStr)
449
3x
    if err != nil {
450
        HandleAppError(c, contextutils.ErrInvalidFormat)
451
        return
452
    }
453

454
    // Get the original question
455
3x
    question, err := h.questionService.GetQuestionByID(c.Request.Context(), questionID)
456
3x
    if err != nil {
457
1x
        h.logger.Error(c.Request.Context(), "Failed to get question", err, map[string]interface{}{"question_id": questionID})
458
1x

459
1x
        // Check if the error is due to question not found
460
1x
        if errors.Is(err, sql.ErrNoRows) {
461
1x
            HandleAppError(c, contextutils.ErrQuestionNotFound)
462
1x
            return
463
1x
        }
464

465
        HandleAppError(c, contextutils.WrapError(err, "failed to get question"))
466
        return
467
    }
468

469
    // Find reporter(s) and choose a configured AI provider/model from the reporting user(s)
470
2x
    ctx := c.Request.Context()
471
2x
    db := h.questionService.DB()
472
2x
    rows, err := db.QueryContext(ctx, `SELECT u.id, u.username, u.ai_provider, u.ai_model, qr.report_reason FROM question_reports qr JOIN users u ON qr.reported_by_user_id = u.id WHERE qr.question_id = $1 ORDER BY qr.created_at ASC`, questionID)
473
2x
    if err != nil {
474
        h.logger.Error(ctx, "Failed to query question reports", err, map[string]interface{}{"question_id": questionID})
475
        HandleAppError(c, contextutils.WrapError(err, "failed to get report details"))
476
        return
477
    }
478
2x
    if err := rows.Err(); err != nil {
479
        h.logger.Warn(ctx, "rows iteration error before defer", map[string]interface{}{"error": err.Error(), "question_id": questionID})
480
    }
481
2x
    defer func() {
482
2x
        if err := rows.Close(); err != nil {
483
            h.logger.Warn(ctx, "Failed to close report rows", map[string]interface{}{"error": err.Error(), "question_id": questionID})
484
        }
485
    }()
486

487
2x
    var reporterID int
488
2x
    var reporterUsername string
489
2x
    var reporterProvider sql.NullString
490
2x
    var reporterModel sql.NullString
491
2x
    var singleReason sql.NullString
492
2x
    foundProvider := false
493
2x

494
2x
    for rows.Next() {
495
        var uid int
496
        var uname string
497
        var prov sql.NullString
498
        var mod sql.NullString
499
        var reason sql.NullString
500
        if err := rows.Scan(&uid, &uname, &prov, &mod, &reason); err != nil {
501
            h.logger.Warn(ctx, "Failed to scan report row", map[string]interface{}{"error": err.Error(), "question_id": questionID})
502
            continue
503
        }
504
        // Prefer the first reporter that has an AI provider+model configured
505
        if prov.Valid && prov.String != "" && mod.Valid && mod.String != "" {
506
            reporterID = uid
507
            reporterUsername = uname
508
            reporterProvider = prov
509
            reporterModel = mod
510
            singleReason = reason
511
            foundProvider = true
512
            break
513
        }
514
        // Keep the first reporter as fallback (no provider)
515
        if reporterID == 0 {
516
            reporterID = uid
517
            reporterUsername = uname
518
            reporterProvider = prov
519
            reporterModel = mod
520
            singleReason = reason
521
        }
522
    }
523

524
2x
    if !foundProvider {
525
2x
        // If no reporting user has AI configured, fall back to admin user's AI settings or global default provider
526
2x
        h.logger.Info(ctx, "No reporting user has AI configured; attempting fallback to admin or global provider", map[string]interface{}{"question_id": questionID})
527
2x

528
2x
        // Try to get current admin user from context/session
529
2x
        var adminUserID int
530
2x
        if uid, err := GetCurrentUserID(c); err == nil {
531
2x
            adminUserID = uid
532
2x
        }
533

534
        // Try admin user's configured provider/model
535
2x
        if adminUserID != 0 {
536
2x
            adminUser, err := h.userService.GetUserByID(ctx, adminUserID)
537
2x
            if err == nil && adminUser != nil && adminUser.AIProvider.Valid && adminUser.AIProvider.String != "" && adminUser.AIModel.Valid && adminUser.AIModel.String != "" {
538
                reporterID = adminUser.ID
539
                reporterUsername = adminUser.Username
540
                reporterProvider = adminUser.AIProvider
541
                reporterModel = adminUser.AIModel
542
                foundProvider = true
543
                h.logger.Info(ctx, "Falling back to admin user's AI provider", map[string]interface{}{"admin_id": adminUserID, "provider": adminUser.AIProvider.String, "model": adminUser.AIModel.String})
544
            }
545
        }
546

547
        // If still not found, try global config first provider
548
2x
        if !foundProvider && h.config != nil && len(h.config.Providers) > 0 {
549
2x
            p := h.config.Providers[0]
550
2x
            if len(p.Models) > 0 {
551
2x
                // Use first provider and model from global config
552
2x
                reporterProvider = sql.NullString{String: p.Code, Valid: true}
553
2x
                reporterModel = sql.NullString{String: p.Models[0].Code, Valid: true}
554
2x
                reporterUsername = "system"
555
2x
                foundProvider = true
556
2x
                h.logger.Info(ctx, "Falling back to global configured AI provider", map[string]interface{}{"provider": p.Code, "model": p.Models[0].Code})
557
2x
            }
558
        }
559

560
2x
        if !foundProvider {
561
            h.logger.Warn(ctx, "No AI provider configured for reporting users and no fallback available", map[string]interface{}{"question_id": questionID})
562
            HandleAppError(c, contextutils.ErrAIConfigInvalid)
563
            return
564
        }
565
    }
566

567
    // Get saved API key for the reporter's configured provider
568
2x
    savedKey, apiKeyID, _ := h.userService.GetUserAPIKeyWithID(ctx, reporterID, reporterProvider.String)
569
2x

570
2x
    userCfg := &models.UserAIConfig{
571
2x
        Provider: reporterProvider.String,
572
2x
        Model:    reporterModel.String,
573
2x
        APIKey:   savedKey,
574
2x
        Username: reporterUsername,
575
2x
    }
576
2x

577
2x
    // Build AI chat request with question details and report reasons
578
2x
    // Use the template manager to render a structured prompt
579
2x
    // Prepare template data
580
2x
    questionContentJSON, _ := question.MarshalContentToJSON()
581
2x
    // Resolve schema for prompt; fail if none
582
2x
    schema, err := services.GetFixSchema(question.Type)
583
2x
    if err != nil {
584
        h.logger.Error(ctx, "No schema available for question type", err, map[string]interface{}{"question_id": questionID, "type": question.Type})
585
        HandleAppError(c, contextutils.ErrAIConfigInvalid)
586
        return
587
    }
588

589
    // Read optional additional_context from POST body JSON
590
2x
    var body struct {
591
2x
        AdditionalContext string `json:"additional_context"`
592
2x
    }
593
2x
    _ = c.BindJSON(&body) // ignore error; body may be empty
594
2x

595
2x
    tmplData := services.AITemplateData{
596
2x
        CurrentQuestionJSON: questionContentJSON,
597
2x
        ExampleContent:      "", // will be filled below if example available
598
2x
        SchemaForPrompt:     schema,
599
2x
        ReportReasons:       []string{},
600
2x
        AdditionalContext:   body.AdditionalContext,
601
2x
    }
602
2x
    if singleReason.Valid {
603
        tmplData.ReportReasons = []string{singleReason.String}
604
    }
605
    // Load example for this question type if available
606
2x
    if ex, err := h.aiService.TemplateManager().LoadExample(string(question.Type)); err == nil {
607
2x
        tmplData.ExampleContent = ex
608
2x
    }
609

610
2x
    prompt, err := h.aiService.TemplateManager().RenderTemplate(services.AIFixPromptTemplate, tmplData)
611
2x
    if err != nil {
612
        h.logger.Error(ctx, "Failed to render AI fix prompt", err, map[string]interface{}{"question_id": questionID})
613
        HandleAppError(c, contextutils.WrapError(err, "failed to build AI prompt"))
614
        return
615
    }
616

617
    // Use schema as grammar for providers that support it
618
2x
    supportsGrammar := h.aiService.SupportsGrammarField(userCfg.Provider)
619
2x
    var grammar string
620
2x
    if supportsGrammar {
621
2x
        grammar, err = services.GetFixSchema(question.Type)
622
2x
        if err != nil {
623
            h.logger.Error(ctx, "No grammar schema available for question type", err, map[string]interface{}{"question_id": questionID, "type": question.Type})
624
            HandleAppError(c, contextutils.ErrAIConfigInvalid)
625
            return
626
        }
627
    } else {
628
        grammar = ""
629
    }
630

631
    // Add user ID and API key ID to context for usage tracking
632
2x
    if reporterID != 0 {
633
        ctx = contextutils.WithUserID(ctx, reporterID)
634
    }
635
2x
    if apiKeyID != nil {
636
        ctx = contextutils.WithAPIKeyID(ctx, *apiKeyID)
637
    }
638

639
    // Call AI service with constructed prompt and grammar
640
2x
    respStr, err := h.aiService.CallWithPrompt(ctx, userCfg, prompt, grammar)
641
2x
    if err != nil {
642
        h.logger.Error(ctx, "AI service call failed", err, map[string]interface{}{"question_id": questionID, "provider": userCfg.Provider})
643
        HandleAppError(c, contextutils.WrapError(err, "AI service error"))
644
        return
645
    }
646

647
    // Attempt to parse AI response as JSON (and try to recover JSON substring if necessary)
648
2x
    var aiResp map[string]interface{}
649
2x
    if err := json.Unmarshal([]byte(respStr), &aiResp); err != nil {
650
        start := strings.Index(respStr, "{")
651
        end := strings.LastIndex(respStr, "}")
652
        if start >= 0 && end > start {
653
            candidate := respStr[start : end+1]
654
            if err2 := json.Unmarshal([]byte(candidate), &aiResp); err2 != nil {
655
                h.logger.Error(ctx, "Failed to parse AI response as JSON", err2, map[string]interface{}{"question_id": questionID})
656
                HandleAppError(c, contextutils.ErrAIResponseInvalid)
657
                return
658
            }
659
        } else {
660
            h.logger.Error(ctx, "AI did not return JSON", nil, map[string]interface{}{"question_id": questionID})
661
            HandleAppError(c, contextutils.ErrAIResponseInvalid)
662
            return
663
        }
664
    }
665

666
    // Start from the original question map so required top-level fields are preserved
667
2x
    originalMap := map[string]interface{}{}
668
2x
    if b, err := json.Marshal(question); err == nil {
669
2x
        _ = json.Unmarshal(b, &originalMap)
670
2x
    }
671

672
    // Use helper to merge and normalize AI suggestion into original map
673
2x
    suggestion := MergeAISuggestion(originalMap, aiResp)
674
2x
    // Attach admin-provided additional context into suggestion metadata so frontend can display it
675
2x
    if body.AdditionalContext != "" {
676
        suggestion["additional_context"] = body.AdditionalContext
677
    }
678

679
    // If query param apply=true present, apply suggestion directly and mark fixed
680
2x
    if strings.ToLower(c.Query("apply")) == "true" {
681
        // Build update payload: use merged content and read answer/explanation from TOP LEVEL
682
        updateContent := suggestion["content"].(map[string]interface{})
683

684
        // Extract correct_answer from top level (support float64 from JSON)
685
        correctAnswer := 0
686
        if ca, ok := suggestion["correct_answer"]; ok {
687
            switch v := ca.(type) {
688
            case float64:
689
                correctAnswer = int(v)
690
            case int:
691
                correctAnswer = v
692
            }
693
        }
694

695
        // Extract explanation from top level
696
        explanation := ""
697
        if ex, ok := suggestion["explanation"].(string); ok {
698
            explanation = ex
699
        }
700

701
        if err := h.questionService.UpdateQuestion(c.Request.Context(), questionID, updateContent, correctAnswer, explanation); err != nil {
702
            h.logger.Error(c.Request.Context(), "Failed to update question with AI suggestion", err, map[string]interface{}{"question_id": questionID})
703
            HandleAppError(c, contextutils.WrapError(err, "failed to apply suggestion"))
704
            return
705
        }
706

707
        if err := h.questionService.MarkQuestionAsFixed(c.Request.Context(), questionID); err != nil {
708
            h.logger.Warn(c.Request.Context(), "Failed to mark question as fixed after applying suggestion", map[string]interface{}{"question_id": questionID, "error": err.Error()})
709
        }
710
        db := h.questionService.DB()
711
        if _, err := db.ExecContext(c.Request.Context(), `DELETE FROM question_reports WHERE question_id = $1`, questionID); err != nil {
712
            h.logger.Warn(c.Request.Context(), "Failed to clear question reports after applying suggestion", map[string]interface{}{"question_id": questionID, "error": err.Error()})
713
        }
714

715
        c.JSON(http.StatusOK, gin.H{"success": true, "message": "Suggestion applied"})
716
        return
717
    }
718

719
    // Return original question and merged AI suggestion for frontend review
720
2x
    c.JSON(http.StatusOK, gin.H{
721
2x
        "original":   question,
722
2x
        "suggestion": suggestion,
723
2x
    })
724
}
725

726
// ServeDatazJS - Removed: Use frontend admin interface instead
727

728
// GetAIConcurrencyStats returns AI service concurrency metrics
729
func (h *AdminHandler) GetAIConcurrencyStats(c *gin.Context) {
730
    // Get stats from the local AI service instance
731
    stats := h.aiService.GetConcurrencyStats()
732
    c.JSON(http.StatusOK, gin.H{
733
        "ai_concurrency": stats,
734
    })
735
}
736

737
// --- Story Explorer (Admin) ---
738

739
// GetStoriesPaginated returns paginated stories with filters
740
4x
func (h *AdminHandler) GetStoriesPaginated(c *gin.Context) {
741
4x
    if h.storyService == nil {
742
        HandleAppError(c, contextutils.ErrInternalError)
743
        return
744
    }
745
4x
    page, pageSize := ParsePagination(c, 1, 20, 100)
746
4x
    f := ParseFilters(c, "search", "language", "status")
747
4x
    search := f["search"]
748
4x
    language := f["language"]
749
4x
    status := f["status"]
750
4x

751
4x
    var userID *uint
752
4x
    if u := c.Query("user_id"); u != "" {
753
1x
        if parsed, err := strconv.Atoi(u); err == nil && parsed > 0 {
754
1x
            tmp := uint(parsed)
755
1x
            userID = &tmp
756
1x
        } else {
757
            HandleAppError(c, contextutils.ErrInvalidFormat)
758
            return
759
        }
760
    }
761

762
4x
    stories, total, err := h.storyService.GetStoriesPaginated(c.Request.Context(), page, pageSize, search, language, status, userID)
763
4x
    if err != nil {
764
        h.logger.Error(c.Request.Context(), "Failed to get stories", err, map[string]interface{}{"page": page, "size": pageSize})
765
        HandleAppError(c, contextutils.WrapError(err, "failed to get stories"))
766
        return
767
    }
768

769
    // Map directly; convert to API struct for consistency
770
4x
    storyMaps := make([]map[string]interface{}, 0, len(stories))
771
4x
    for _, s := range stories {
772
6x
        apiS := convertStoryToAPI(&s)
773
6x
        m := map[string]interface{}{}
774
6x
        if b, err := json.Marshal(apiS); err == nil {
775
6x
            _ = json.Unmarshal(b, &m)
776
6x
        }
777
6x
        storyMaps = append(storyMaps, m)
778
    }
779

780
4x
    c.JSON(http.StatusOK, gin.H{
781
4x
        "stories": storyMaps,
782
4x
        "pagination": gin.H{
783
4x
            "page":        page,
784
4x
            "page_size":   pageSize,
785
4x
            "total":       total,
786
4x
            "total_pages": int(math.Ceil(float64(total) / float64(pageSize))),
787
4x
        },
788
4x
    })
789
}
790

791
// GetStoryAdmin returns a full story with sections by ID
792
2x
func (h *AdminHandler) GetStoryAdmin(c *gin.Context) {
793
2x
    if h.storyService == nil {
794
        HandleAppError(c, contextutils.ErrInternalError)
795
        return
796
    }
797
2x
    idStr := c.Param("id")
798
2x
    id, err := strconv.Atoi(idStr)
799
2x
    if err != nil || id <= 0 {
800
        HandleAppError(c, contextutils.ErrInvalidFormat)
801
        return
802
    }
803
2x
    story, err := h.storyService.GetStoryAdmin(c.Request.Context(), uint(id))
804
2x
    if err != nil {
805
1x
        h.logger.Error(c.Request.Context(), "Failed to get story", err, map[string]interface{}{"story_id": id})
806
1x
        if strings.Contains(err.Error(), "story not found") {
807
1x
            HandleAppError(c, contextutils.ErrRecordNotFound)
808
1x
            return
809
1x
        }
810
        HandleAppError(c, contextutils.WrapError(err, "failed to get story"))
811
        return
812
    }
813
1x
    c.JSON(http.StatusOK, convertStoryWithSectionsToAPI(story))
814
}
815

816
// GetSectionAdmin returns a section with questions by ID
817
2x
func (h *AdminHandler) GetSectionAdmin(c *gin.Context) {
818
2x
    if h.storyService == nil {
819
        HandleAppError(c, contextutils.ErrInternalError)
820
        return
821
    }
822
2x
    idStr := c.Param("id")
823
2x
    id, err := strconv.Atoi(idStr)
824
2x
    if err != nil || id <= 0 {
825
        HandleAppError(c, contextutils.ErrInvalidFormat)
826
        return
827
    }
828
2x
    section, err := h.storyService.GetSectionAdmin(c.Request.Context(), uint(id))
829
2x
    if err != nil {
830
1x
        h.logger.Error(c.Request.Context(), "Failed to get section", err, map[string]interface{}{"section_id": id})
831
1x
        if strings.Contains(err.Error(), "section not found") {
832
1x
            HandleAppError(c, contextutils.ErrRecordNotFound)
833
1x
            return
834
1x
        }
835
        HandleAppError(c, contextutils.WrapError(err, "failed to get section"))
836
        return
837
    }
838
1x
    c.JSON(http.StatusOK, convertStorySectionWithQuestionsToAPI(section))
839
}
840

841
// DeleteStoryAdmin deletes a story by ID (admin only). Only archived or completed stories can be deleted.
842
func (h *AdminHandler) DeleteStoryAdmin(c *gin.Context) {
843
    if h.storyService == nil {
844
        HandleAppError(c, contextutils.ErrInternalError)
845
        return
846
    }
847
    idStr := c.Param("id")
848
    id, err := strconv.Atoi(idStr)
849
    if err != nil || id <= 0 {
850
        HandleAppError(c, contextutils.ErrInvalidFormat)
851
        return
852
    }
853

854
    if err := h.storyService.DeleteStoryAdmin(c.Request.Context(), uint(id)); err != nil {
855
        h.logger.Error(c.Request.Context(), "Failed to delete story (admin)", err, map[string]interface{}{"story_id": id})
856

857
        if strings.Contains(err.Error(), "not found") {
858
            HandleAppError(c, contextutils.ErrRecordNotFound)
859
            return
860
        }
861
        if strings.Contains(err.Error(), "cannot delete active story") {
862
            HandleAppError(c, contextutils.ErrConflict)
863
            return
864
        }
865
        HandleAppError(c, contextutils.WrapError(err, "failed to delete story"))
866
        return
867
    }
868

869
    c.JSON(http.StatusOK, gin.H{"message": "Story deleted successfully"})
870
}
871

872
// ClearUserData removes all user activity data but keeps the users themselves
873
1x
func (h *AdminHandler) ClearUserData(c *gin.Context) {
874
1x
    err := h.userService.ClearUserData(c.Request.Context())
875
1x
    if err != nil {
876
        h.logger.Error(c.Request.Context(), "Failed to clear user data", err, map[string]interface{}{})
877
        HandleAppError(c, contextutils.WrapError(err, "failed to clear user data"))
878
        return
879
    }
880

881
1x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "User data cleared successfully (users preserved)"})
882
}
883

884
// ClearDatabase completely resets the database to an empty state
885
1x
func (h *AdminHandler) ClearDatabase(c *gin.Context) {
886
1x
    err := h.userService.ResetDatabase(c.Request.Context())
887
1x
    if err != nil {
888
        h.logger.Error(c.Request.Context(), "Failed to clear database", err, map[string]interface{}{})
889
        HandleAppError(c, contextutils.WrapError(err, "failed to clear database"))
890
        return
891
    }
892

893
1x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "Database cleared successfully"})
894
}
895

896
// GetQuestion returns a single question by ID for editing
897
2x
func (h *AdminHandler) GetQuestion(c *gin.Context) {
898
2x
    questionIDStr := c.Param("id")
899
2x
    questionID, err := strconv.Atoi(questionIDStr)
900
2x
    if err != nil {
901
        HandleAppError(c, contextutils.ErrInvalidFormat)
902
        return
903
    }
904

905
2x
    question, err := h.questionService.GetQuestionByID(c.Request.Context(), questionID)
906
2x
    if err != nil {
907
1x
        h.logger.Error(c.Request.Context(), "Failed to get question", err, map[string]interface{}{"question_id": questionID})
908
1x
        HandleAppError(c, contextutils.ErrQuestionNotFound)
909
1x
        return
910
1x
    }
911

912
1x
    c.JSON(http.StatusOK, question)
913
}
914

915
// GetUsersForQuestion returns the users assigned to a question
916
2x
func (h *AdminHandler) GetUsersForQuestion(c *gin.Context) {
917
2x
    questionIDStr := c.Param("id")
918
2x
    questionID, err := strconv.Atoi(questionIDStr)
919
2x
    if err != nil {
920
        HandleAppError(c, contextutils.ErrInvalidFormat)
921
        return
922
    }
923

924
2x
    users, totalCount, err := h.questionService.GetUsersForQuestion(c.Request.Context(), questionID)
925
2x
    if err != nil {
926
        h.logger.Error(c.Request.Context(), "Failed to get users for question", err, map[string]interface{}{"question_id": questionID})
927
        HandleAppError(c, contextutils.WrapError(err, "failed to get users for question"))
928
        return
929
    }
930

931
2x
    c.JSON(http.StatusOK, gin.H{
932
2x
        "users":       users,
933
2x
        "total_count": totalCount,
934
2x
    })
935
}
936

937
// AssignUsersToQuestion assigns multiple users to a question
938
2x
func (h *AdminHandler) AssignUsersToQuestion(c *gin.Context) {
939
2x
    questionIDStr := c.Param("id")
940
2x
    questionID, err := strconv.Atoi(questionIDStr)
941
2x
    if err != nil {
942
        HandleAppError(c, contextutils.ErrInvalidFormat)
943
        return
944
    }
945

946
2x
    var request struct {
947
2x
        UserIDs []int `json:"user_ids" binding:"required"`
948
2x
    }
949
2x

950
2x
    if err := c.ShouldBindJSON(&request); err != nil {
951
        HandleAppError(c, contextutils.ErrInvalidInput)
952
        return
953
    }
954

955
    // Validate non-empty user list
956
2x
    if len(request.UserIDs) == 0 {
957
        HandleAppError(c, contextutils.ErrInvalidInput)
958
        return
959
    }
960

961
    // Check if the question exists first
962
2x
    _, err = h.questionService.GetQuestionByID(c.Request.Context(), questionID)
963
2x
    if err != nil {
964
1x
        h.logger.Error(c.Request.Context(), "Failed to get question", err, map[string]interface{}{"question_id": questionID})
965
1x

966
1x
        // Check if the error is due to question not found
967
1x
        if errors.Is(err, sql.ErrNoRows) {
968
1x
            HandleAppError(c, contextutils.ErrQuestionNotFound)
969
1x
            return
970
1x
        }
971

972
        HandleAppError(c, contextutils.WrapError(err, "failed to get question"))
973
        return
974
    }
975

976
1x
    err = h.questionService.AssignUsersToQuestion(c.Request.Context(), questionID, request.UserIDs)
977
1x
    if err != nil {
978
        h.logger.Error(c.Request.Context(), "Failed to assign users to question", err, map[string]interface{}{
979
            "question_id": questionID,
980
            "user_ids":    request.UserIDs,
981
        })
982
        HandleAppError(c, contextutils.WrapError(err, "failed to assign users to question"))
983
        return
984
    }
985

986
1x
    c.JSON(http.StatusOK, gin.H{"message": "Users assigned to question successfully"})
987
}
988

989
// UnassignUsersFromQuestion removes multiple users from a question
990
2x
func (h *AdminHandler) UnassignUsersFromQuestion(c *gin.Context) {
991
2x
    questionIDStr := c.Param("id")
992
2x
    questionID, err := strconv.Atoi(questionIDStr)
993
2x
    if err != nil {
994
        HandleAppError(c, contextutils.ErrInvalidFormat)
995
        return
996
    }
997

998
2x
    var request struct {
999
2x
        UserIDs []int `json:"user_ids" binding:"required"`
1000
2x
    }
1001
2x

1002
2x
    if err := c.ShouldBindJSON(&request); err != nil {
1003
        HandleAppError(c, contextutils.NewAppErrorWithCause(contextutils.ErrorCodeInvalidInput, contextutils.SeverityWarn, "Invalid request body", "", err))
1004
        return
1005
    }
1006

1007
    // Validate non-empty user list
1008
2x
    if len(request.UserIDs) == 0 {
1009
        HandleAppError(c, contextutils.ErrInvalidInput)
1010
        return
1011
    }
1012

1013
    // Check if the question exists first
1014
2x
    _, err = h.questionService.GetQuestionByID(c.Request.Context(), questionID)
1015
2x
    if err != nil {
1016
1x
        h.logger.Error(c.Request.Context(), "Failed to get question", err, map[string]interface{}{"question_id": questionID})
1017
1x

1018
1x
        // Check if the error is due to question not found
1019
1x
        if errors.Is(err, sql.ErrNoRows) {
1020
1x
            HandleAppError(c, contextutils.ErrQuestionNotFound)
1021
1x
            return
1022
1x
        }
1023

1024
        HandleAppError(c, contextutils.WrapError(err, "failed to get question"))
1025
        return
1026
    }
1027

1028
1x
    err = h.questionService.UnassignUsersFromQuestion(c.Request.Context(), questionID, request.UserIDs)
1029
1x
    if err != nil {
1030
        h.logger.Error(c.Request.Context(), "Failed to unassign users from question", err, map[string]interface{}{
1031
            "question_id": questionID,
1032
            "user_ids":    request.UserIDs,
1033
        })
1034
        HandleAppError(c, contextutils.WrapError(err, "failed to unassign users from question"))
1035
        return
1036
    }
1037

1038
1x
    c.JSON(http.StatusOK, gin.H{"message": "Users unassigned from question successfully"})
1039
}
1040

1041
// DeleteQuestion deletes a question by ID
1042
1x
func (h *AdminHandler) DeleteQuestion(c *gin.Context) {
1043
1x
    questionIDStr := c.Param("id")
1044
1x
    questionID, err := strconv.Atoi(questionIDStr)
1045
1x
    if err != nil {
1046
        HandleAppError(c, contextutils.ErrInvalidFormat)
1047
        return
1048
    }
1049

1050
1x
    err = h.questionService.DeleteQuestion(c.Request.Context(), questionID)
1051
1x
    if err != nil {
1052
        h.logger.Error(c.Request.Context(), "Failed to delete question", err, map[string]interface{}{"question_id": questionID})
1053

1054
        // Check if the error is due to question not found
1055
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
1056
            HandleAppError(c, contextutils.ErrQuestionNotFound)
1057
            return
1058
        }
1059

1060
        HandleAppError(c, contextutils.WrapError(err, "failed to delete question"))
1061
        return
1062
    }
1063

1064
1x
    c.JSON(http.StatusOK, gin.H{"message": "Question deleted successfully"})
1065
}
1066

1067
// GetQuestionsPaginated returns paginated questions with response statistics
1068
1x
func (h *AdminHandler) GetQuestionsPaginated(c *gin.Context) {
1069
1x
    userIDStr := c.Query("user_id")
1070
1x
    if userIDStr == "" {
1071
        HandleAppError(c, contextutils.ErrMissingRequired)
1072
        return
1073
    }
1074

1075
1x
    userID, err := strconv.Atoi(userIDStr)
1076
1x
    if err != nil {
1077
        HandleAppError(c, contextutils.ErrInvalidFormat)
1078
        return
1079
    }
1080

1081
    // Parse pagination and filters
1082
1x
    page, pageSize := ParsePagination(c, 1, 10, 100)
1083
1x
    filters := ParseFilters(c, "search", "type", "status")
1084
1x
    search := filters["search"]
1085
1x
    typeFilter := filters["type"]
1086
1x
    statusFilter := filters["status"]
1087
1x

1088
1x
    // Get questions with filters
1089
1x
    questions, total, err := h.questionService.GetQuestionsPaginated(
1090
1x
        c.Request.Context(),
1091
1x
        userID,
1092
1x
        page,
1093
1x
        pageSize,
1094
1x
        search,
1095
1x
        typeFilter,
1096
1x
        statusFilter,
1097
1x
    )
1098
1x
    if err != nil {
1099
        h.logger.Error(c.Request.Context(), "Failed to get paginated questions", err, map[string]interface{}{
1100
            "user_id": userID,
1101
            "page":    page,
1102
            "size":    pageSize,
1103
        })
1104
        HandleAppError(c, contextutils.WrapError(err, "failed to get questions"))
1105
        return
1106
    }
1107

1108
1x
    stats, err := h.questionService.GetQuestionStats(c.Request.Context())
1109
1x
    if err != nil {
1110
        h.logger.Warn(c.Request.Context(), "Failed to get question stats", map[string]interface{}{"error": err.Error()})
1111
        stats = map[string]interface{}{}
1112
    }
1113

1114
1x
    c.JSON(http.StatusOK, gin.H{
1115
1x
        "questions": func() []map[string]interface{} {
1116
1x
            out := make([]map[string]interface{}, 0, len(questions))
1117
1x
            for _, q := range questions {
1118
                m, err := convertQuestionWithStatsToAPIMap(c.Request.Context(), q)
1119
                if err != nil {
1120
                    h.logger.Error(c.Request.Context(), "Failed to convert question to API", err, map[string]interface{}{
1121
                        "question_id": func() int {
1122
                            if q != nil && q.Question != nil {
1123
                                return int(q.ID)
1124
                            }
1125
                            return 0
1126
                        }(),
1127
                    })
1128
                    // Include invalid questions with error indicator for admin visibility
1129
                    out = append(out, buildInvalidQuestionMap(q, err))
1130
                    continue
1131
                }
1132
                out = append(out, m)
1133
            }
1134
1x
            return out
1135
        }(),
1136
        "pagination": gin.H{
1137
            "page":        page,
1138
            "page_size":   pageSize,
1139
            "total":       total,
1140
            "total_pages": int(math.Ceil(float64(total) / float64(pageSize))),
1141
        },
1142
        "stats": stats,
1143
    })
1144
}
1145

1146
// GetAllQuestions returns all questions with pagination and filtering
1147
func (h *AdminHandler) GetAllQuestions(c *gin.Context) {
1148
    // Parse pagination and filters
1149
    page, pageSize := ParsePagination(c, 1, 20, 100)
1150
    f := ParseFilters(c, "search", "type", "status", "language", "level")
1151
    search := f["search"]
1152
    typeFilter := f["type"]
1153
    statusFilter := f["status"]
1154
    languageFilter := f["language"]
1155
    levelFilter := f["level"]
1156
    userIDStr := c.Query("user_id")
1157

1158
    // Parse user_id if provided
1159
    var userID *int
1160
    if userIDStr != "" {
1161
        uid, err := strconv.Atoi(userIDStr)
1162
        if err != nil {
1163
            HandleAppError(c, contextutils.ErrInvalidFormat)
1164
            return
1165
        }
1166
        userID = &uid
1167
    }
1168

1169
    // Get questions with filters
1170
    questions, total, err := h.questionService.GetAllQuestionsPaginated(
1171
        c.Request.Context(),
1172
        page,
1173
        pageSize,
1174
        search,
1175
        typeFilter,
1176
        statusFilter,
1177
        languageFilter,
1178
        levelFilter,
1179
        userID,
1180
    )
1181
    if err != nil {
1182
        h.logger.Error(c.Request.Context(), "Failed to get all questions", err, map[string]interface{}{
1183
            "page":   page,
1184
            "size":   pageSize,
1185
            "search": search,
1186
        })
1187
        HandleAppError(c, contextutils.WrapError(err, "failed to get questions"))
1188
        return
1189
    }
1190

1191
    // Get stats
1192
    stats, err := h.questionService.GetQuestionStats(c.Request.Context())
1193
    if err != nil {
1194
        h.logger.Warn(c.Request.Context(), "Failed to get question stats", map[string]interface{}{"error": err.Error()})
1195
        stats = map[string]interface{}{}
1196
    }
1197

1198
    c.JSON(http.StatusOK, gin.H{
1199
        "questions": func() []map[string]interface{} {
1200
            out := make([]map[string]interface{}, 0, len(questions))
1201
            for _, q := range questions {
1202
                m, err := convertQuestionWithStatsToAPIMap(c.Request.Context(), q)
1203
                if err != nil {
1204
                    h.logger.Error(c.Request.Context(), "Failed to convert question to API", err, map[string]interface{}{
1205
                        "question_id": func() int {
1206
                            if q != nil && q.Question != nil {
1207
                                return int(q.ID)
1208
                            }
1209
                            return 0
1210
                        }(),
1211
                    })
1212
                    // Include invalid questions with error indicator for admin visibility
1213
                    out = append(out, buildInvalidQuestionMap(q, err))
1214
                    continue
1215
                }
1216
                out = append(out, m)
1217
            }
1218
            return out
1219
        }(),
1220
        "pagination": gin.H{
1221
            "page":        page,
1222
            "page_size":   pageSize,
1223
            "total":       total,
1224
            "total_pages": int(math.Ceil(float64(total) / float64(pageSize))),
1225
        },
1226
        "stats": stats,
1227
    })
1228
}
1229

1230
// GetReportedQuestionsPaginated returns reported questions with pagination and filtering
1231
1x
func (h *AdminHandler) GetReportedQuestionsPaginated(c *gin.Context) {
1232
1x
    // Parse pagination and filters
1233
1x
    page, pageSize := ParsePagination(c, 1, 20, 100)
1234
1x
    f := ParseFilters(c, "search", "type", "language", "level")
1235
1x
    search := f["search"]
1236
1x
    typeFilter := f["type"]
1237
1x
    languageFilter := f["language"]
1238
1x
    levelFilter := f["level"]
1239
1x

1240
1x
    // Get reported questions with filters
1241
1x
    questions, total, err := h.questionService.GetReportedQuestionsPaginated(
1242
1x
        c.Request.Context(),
1243
1x
        page,
1244
1x
        pageSize,
1245
1x
        search,
1246
1x
        typeFilter,
1247
1x
        languageFilter,
1248
1x
        levelFilter,
1249
1x
    )
1250
1x
    if err != nil {
1251
        h.logger.Error(c.Request.Context(), "Failed to get reported questions", err, map[string]interface{}{
1252
            "page":   page,
1253
            "size":   pageSize,
1254
            "search": search,
1255
        })
1256
        HandleAppError(c, contextutils.WrapError(err, "failed to get reported questions"))
1257
        return
1258
    }
1259

1260
    // Get reported questions stats
1261
1x
    stats, err := h.questionService.GetReportedQuestionsStats(c.Request.Context())
1262
1x
    if err != nil {
1263
        h.logger.Warn(c.Request.Context(), "Failed to get reported questions stats", map[string]interface{}{"error": err.Error()})
1264
        stats = map[string]interface{}{}
1265
    }
1266

1267
1x
    c.JSON(http.StatusOK, gin.H{
1268
1x
        "questions": func() []map[string]interface{} {
1269
1x
            out := make([]map[string]interface{}, 0, len(questions))
1270
1x
            for _, q := range questions {
1271
1x
                m, err := convertQuestionWithStatsToAPIMap(c.Request.Context(), q)
1272
1x
                if err != nil {
1273
                    h.logger.Error(c.Request.Context(), "Failed to convert question to API", err, map[string]interface{}{
1274
                        "question_id": func() int {
1275
                            if q != nil && q.Question != nil {
1276
                                return int(q.ID)
1277
                            }
1278
                            return 0
1279
                        }(),
1280
                    })
1281
                    // Include invalid questions with error indicator for admin visibility
1282
                    out = append(out, buildInvalidQuestionMap(q, err))
1283
                    continue
1284
                }
1285
1x
                out = append(out, m)
1286
            }
1287
1x
            return out
1288
        }(),
1289
        "pagination": gin.H{
1290
            "page":        page,
1291
            "page_size":   pageSize,
1292
            "total":       total,
1293
            "total_pages": int(math.Ceil(float64(total) / float64(pageSize))),
1294
        },
1295
        "stats": stats,
1296
    })
1297
}
1298

1299
// ClearUserDataForUser removes all user activity data for a specific user but keeps the user record
1300
1x
func (h *AdminHandler) ClearUserDataForUser(c *gin.Context) {
1301
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "clear_user_data_for_user")
1302
1x
    defer observability.FinishSpan(span, nil)
1303
1x
    userIDStr := c.Param("id")
1304
1x
    userID, err := strconv.Atoi(userIDStr)
1305
1x
    if err != nil {
1306
        HandleAppError(c, contextutils.ErrInvalidFormat)
1307
        return
1308
    }
1309

1310
    // Check if user exists before attempting to clear data
1311
1x
    user, err := h.userService.GetUserByID(ctx, userID)
1312
1x
    if err != nil {
1313
        h.logger.Error(ctx, "Failed to get user for clear data operation", err, map[string]interface{}{"user_id": userID})
1314
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
1315
        return
1316
    }
1317
1x
    if user == nil {
1318
        HandleAppError(c, contextutils.ErrRecordNotFound)
1319
        return
1320
    }
1321

1322
1x
    err = h.userService.ClearUserDataForUser(ctx, userID)
1323
1x
    if err != nil {
1324
        h.logger.Error(ctx, "Failed to clear user data for user", err, map[string]interface{}{"user_id": userID})
1325
        HandleAppError(c, contextutils.WrapError(err, "failed to clear user data for user"))
1326
        return
1327
    }
1328
1x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "User data cleared successfully (user preserved)"})
1329
}
1330

1331
// GetConfigz returns the merged config as pretty-printed JSON
1332
2x
func (h *AdminHandler) GetConfigz(c *gin.Context) {
1333
2x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_configz")
1334
2x
    defer observability.FinishSpan(span, nil)
1335
2x
    c.IndentedJSON(http.StatusOK, h.config)
1336
2x
}
1337

1338
// GetRoles returns all available roles in the system
1339
func (h *AdminHandler) GetRoles(c *gin.Context) {
1340
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_roles")
1341
    defer observability.FinishSpan(span, nil)
1342

1343
    // For now, return hardcoded roles since we don't have a role service
1344
    // In a real implementation, you'd query the database
1345
    roles := []models.Role{
1346
        {ID: 1, Name: "user", Description: "Normal site access", CreatedAt: time.Now(), UpdatedAt: time.Now()},
1347
        {ID: 2, Name: "admin", Description: "Administrative access to all features", CreatedAt: time.Now(), UpdatedAt: time.Now()},
1348
    }
1349

1350
    c.JSON(http.StatusOK, gin.H{"roles": roles})
1351
}
1352

1353
// GetUserRoles returns all roles for a specific user
1354
func (h *AdminHandler) GetUserRoles(c *gin.Context) {
1355
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_user_roles")
1356
    defer observability.FinishSpan(span, nil)
1357

1358
    userIDStr := c.Param("id")
1359
    userID, err := strconv.Atoi(userIDStr)
1360
    if err != nil {
1361
        HandleAppError(c, contextutils.ErrInvalidFormat)
1362
        return
1363
    }
1364

1365
    // Check if user exists before getting roles
1366
    user, err := h.userService.GetUserByID(ctx, userID)
1367
    if err != nil {
1368
        h.logger.Error(ctx, "Failed to get user for roles operation", err, map[string]interface{}{"user_id": userID})
1369
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
1370
        return
1371
    }
1372
    if user == nil {
1373
        HandleAppError(c, contextutils.ErrRecordNotFound)
1374
        return
1375
    }
1376

1377
    roles, err := h.userService.GetUserRoles(ctx, userID)
1378
    if err != nil {
1379
        h.logger.Error(ctx, "Failed to get user roles", err, map[string]interface{}{"user_id": userID})
1380
        HandleAppError(c, contextutils.WrapError(err, "failed to get user roles"))
1381
        return
1382
    }
1383

1384
    c.JSON(http.StatusOK, gin.H{"roles": roles})
1385
}
1386

1387
// AssignRole assigns a role to a user
1388
func (h *AdminHandler) AssignRole(c *gin.Context) {
1389
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "assign_role")
1390
    defer observability.FinishSpan(span, nil)
1391

1392
    userIDStr := c.Param("id")
1393
    userID, err := strconv.Atoi(userIDStr)
1394
    if err != nil {
1395
        HandleAppError(c, contextutils.ErrInvalidFormat)
1396
        return
1397
    }
1398

1399
    // Check if user exists before assigning role
1400
    user, err := h.userService.GetUserByID(ctx, userID)
1401
    if err != nil {
1402
        h.logger.Error(ctx, "Failed to get user for role assignment", err, map[string]interface{}{"user_id": userID})
1403
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
1404
        return
1405
    }
1406
    if user == nil {
1407
        HandleAppError(c, contextutils.ErrRecordNotFound)
1408
        return
1409
    }
1410

1411
    var req struct {
1412
        RoleID int `json:"role_id" binding:"required"`
1413
    }
1414
    if err := c.ShouldBindJSON(&req); err != nil {
1415
        HandleAppError(c, contextutils.NewAppErrorWithCause(contextutils.ErrorCodeInvalidInput, contextutils.SeverityWarn, "Invalid request body", "", err))
1416
        return
1417
    }
1418

1419
    // Ensure the requester is allowed (self or admin). Route is admin-only, but keep explicit check.
1420
    currentUserID, err := GetCurrentUserID(c)
1421
    if err == nil {
1422
        if err := RequireSelfOrAdmin(ctx, h.userService, currentUserID, userID); err != nil {
1423
            if errors.Is(err, ErrForbidden) {
1424
                HandleAppError(c, contextutils.ErrForbidden)
1425
                return
1426
            }
1427
            h.logger.Error(ctx, "Failed to check authorization", err, map[string]interface{}{"user_id": currentUserID})
1428
            HandleAppError(c, contextutils.WrapError(err, "failed to check authorization"))
1429
            return
1430
        }
1431
    }
1432

1433
    err = h.userService.AssignRole(ctx, userID, req.RoleID)
1434
    if err != nil {
1435
        h.logger.Error(ctx, "Failed to assign role to user", err, map[string]interface{}{"user_id": userID, "role_id": req.RoleID})
1436
        HandleAppError(c, contextutils.WrapError(err, "failed to assign role"))
1437
        return
1438
    }
1439

1440
    c.JSON(http.StatusOK, gin.H{"message": "Role assigned successfully"})
1441
}
1442

1443
// RemoveRole removes a role from a user
1444
func (h *AdminHandler) RemoveRole(c *gin.Context) {
1445
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "remove_role")
1446
    defer observability.FinishSpan(span, nil)
1447

1448
    userIDStr := c.Param("id")
1449
    userID, err := strconv.Atoi(userIDStr)
1450
    if err != nil {
1451
        HandleAppError(c, contextutils.ErrInvalidFormat)
1452
        return
1453
    }
1454

1455
    // Check if user exists before removing role
1456
    user, err := h.userService.GetUserByID(ctx, userID)
1457
    if err != nil {
1458
        h.logger.Error(ctx, "Failed to get user for role removal", err, map[string]interface{}{"user_id": userID})
1459
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
1460
        return
1461
    }
1462
    if user == nil {
1463
        HandleAppError(c, contextutils.ErrRecordNotFound)
1464
        return
1465
    }
1466

1467
    roleIDStr := c.Param("roleId")
1468
    roleID, err := strconv.Atoi(roleIDStr)
1469
    if err != nil {
1470
        HandleAppError(c, contextutils.ErrInvalidFormat)
1471
        return
1472
    }
1473

1474
    // Ensure the requester is allowed (self or admin). Route is admin-only, but keep explicit check.
1475
    currentUserID, err := GetCurrentUserID(c)
1476
    if err == nil {
1477
        if err := RequireSelfOrAdmin(ctx, h.userService, currentUserID, userID); err != nil {
1478
            if errors.Is(err, ErrForbidden) {
1479
                HandleAppError(c, contextutils.ErrForbidden)
1480
                return
1481
            }
1482
            h.logger.Error(ctx, "Failed to check authorization", err, map[string]interface{}{"user_id": currentUserID})
1483
            HandleAppError(c, contextutils.WrapError(err, "failed to check authorization"))
1484
            return
1485
        }
1486
    }
1487

1488
    err = h.userService.RemoveRole(ctx, userID, roleID)
1489
    if err != nil {
1490
        h.logger.Error(ctx, "Failed to remove role", err, map[string]interface{}{"user_id": userID, "role_id": roleID})
1491

1492
        // Check if it's a "user does not have role" error
1493
        if strings.Contains(err.Error(), "does not have role") {
1494
            HandleAppError(c, contextutils.ErrRecordNotFound)
1495
            return
1496
        }
1497

1498
        // Check if it's a "user not found" or "role not found" error
1499
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
1500
            HandleAppError(c, contextutils.ErrRecordNotFound)
1501
            return
1502
        }
1503

1504
        HandleAppError(c, contextutils.WrapError(err, "failed to remove role"))
1505
        return
1506
    }
1507

1508
    c.JSON(http.StatusOK, gin.H{"message": "Role removed successfully"})
1509
}
1510

1511
// GetUsageStats returns usage statistics for the admin interface
1512
func (h *AdminHandler) GetUsageStats(c *gin.Context) {
1513
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_usage_stats")
1514
    defer observability.FinishSpan(span, nil)
1515

1516
    if h.usageStatsSvc == nil {
1517
        HandleAppError(c, contextutils.ErrInternalError)
1518
        return
1519
    }
1520

1521
    // Get all usage stats
1522
    stats, err := h.usageStatsSvc.GetAllUsageStats(ctx)
1523
    if err != nil {
1524
        h.logger.Error(ctx, "Failed to get usage stats", err, map[string]interface{}{})
1525
        HandleAppError(c, contextutils.WrapError(err, "failed to get usage stats"))
1526
        return
1527
    }
1528

1529
    // Group stats by service and month for easier frontend consumption
1530
    serviceStats := make(map[string]map[string]map[string]interface{})
1531
    monthlyTotals := make(map[string]map[string]interface{})
1532

1533
    // Track cache statistics across all services
1534
    var totalCacheHitsRequests, totalCacheHitsCharacters, totalCacheMissesRequests int
1535

1536
    for _, stat := range stats {
1537
        serviceName := stat.ServiceName
1538
        usageType := stat.UsageType
1539
        month := stat.UsageMonth.Format("2006-01")
1540

1541
        if serviceStats[serviceName] == nil {
1542
            serviceStats[serviceName] = make(map[string]map[string]interface{})
1543
        }
1544
        if serviceStats[serviceName][month] == nil {
1545
            serviceStats[serviceName][month] = make(map[string]interface{})
1546
        }
1547

1548
        serviceStats[serviceName][month][usageType] = map[string]interface{}{
1549
            "characters_used": stat.CharactersUsed,
1550
            "requests_made":   stat.RequestsMade,
1551
            "quota":           h.usageStatsSvc.GetMonthlyQuota(serviceName),
1552
        }
1553

1554
        // Accumulate cache statistics
1555
        switch usageType {
1556
        case "translation_cache_hit":
1557
            totalCacheHitsRequests += stat.RequestsMade
1558
            totalCacheHitsCharacters += stat.CharactersUsed
1559
        case "translation_cache_miss":
1560
            totalCacheMissesRequests += stat.RequestsMade
1561
        }
1562

1563
        // Accumulate monthly totals (only for actual translations, not cache)
1564
        if usageType == "translation" {
1565
            if monthlyTotals[month] == nil {
1566
                monthlyTotals[month] = make(map[string]interface{})
1567
            }
1568
            if monthlyTotals[month][serviceName] == nil {
1569
                monthlyTotals[month][serviceName] = map[string]interface{}{
1570
                    "total_characters": 0,
1571
                    "total_requests":   0,
1572
                }
1573
            }
1574

1575
            totalChars := monthlyTotals[month][serviceName].(map[string]interface{})["total_characters"].(int) + stat.CharactersUsed
1576
            totalReqs := monthlyTotals[month][serviceName].(map[string]interface{})["total_requests"].(int) + stat.RequestsMade
1577

1578
            monthlyTotals[month][serviceName].(map[string]interface{})["total_characters"] = totalChars
1579
            monthlyTotals[month][serviceName].(map[string]interface{})["total_requests"] = totalReqs
1580
        }
1581
    }
1582

1583
    // Calculate cache hit rate
1584
    totalCacheRequests := totalCacheHitsRequests + totalCacheMissesRequests
1585
    var cacheHitRate float64
1586
    if totalCacheRequests > 0 {
1587
        cacheHitRate = (float64(totalCacheHitsRequests) / float64(totalCacheRequests)) * 100
1588
    }
1589

1590
    c.JSON(http.StatusOK, gin.H{
1591
        "usage_stats":    serviceStats,
1592
        "monthly_totals": monthlyTotals,
1593
        "services":       []string{"google"}, // Currently only Google Translate
1594
        "cache_stats": gin.H{
1595
            "total_cache_hits_requests":   totalCacheHitsRequests,
1596
            "total_cache_hits_characters": totalCacheHitsCharacters,
1597
            "total_cache_misses_requests": totalCacheMissesRequests,
1598
            "cache_hit_rate":              cacheHitRate,
1599
        },
1600
    })
1601
}
1602

1603
// GetUsageStatsByService returns usage statistics for a specific service
1604
func (h *AdminHandler) GetUsageStatsByService(c *gin.Context) {
1605
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_usage_stats_by_service")
1606
    defer observability.FinishSpan(span, nil)
1607

1608
    serviceName := c.Param("service")
1609
    if serviceName == "" {
1610
        HandleAppError(c, contextutils.ErrInvalidFormat)
1611
        return
1612
    }
1613

1614
    // Validate service name against configured translation providers
1615
    if !h.config.Translation.Enabled {
1616
        HandleAppError(c, contextutils.ErrInvalidFormat)
1617
        return
1618
    }
1619

1620
    isValidService := false
1621
    for providerCode := range h.config.Translation.Providers {
1622
        if providerCode == serviceName {
1623
            isValidService = true
1624
            break
1625
        }
1626
    }
1627

1628
    if !isValidService {
1629
        HandleAppError(c, contextutils.ErrInvalidFormat)
1630
        return
1631
    }
1632

1633
    if h.usageStatsSvc == nil {
1634
        HandleAppError(c, contextutils.ErrInternalError)
1635
        return
1636
    }
1637

1638
    stats, err := h.usageStatsSvc.GetUsageStatsByService(ctx, serviceName)
1639
    if err != nil {
1640
        h.logger.Error(ctx, "Failed to get usage stats by service", err, map[string]interface{}{"service": serviceName})
1641
        HandleAppError(c, contextutils.WrapError(err, "failed to get usage stats"))
1642
        return
1643
    }
1644

1645
    // Format for frontend consumption
1646
    monthlyData := make([]map[string]interface{}, 0)
1647
    for _, stat := range stats {
1648
        // Only show quota for actual translation usage, not for cache hits/misses
1649
        var quota interface{}
1650
        if stat.UsageType == "translation" {
1651
            quota = h.usageStatsSvc.GetMonthlyQuota(serviceName)
1652
        } else {
1653
            quota = nil
1654
        }
1655

1656
        monthlyData = append(monthlyData, map[string]interface{}{
1657
            "month":           stat.UsageMonth.Format("2006-01"),
1658
            "usage_type":      stat.UsageType,
1659
            "characters_used": stat.CharactersUsed,
1660
            "requests_made":   stat.RequestsMade,
1661
            "quota":           quota,
1662
        })
1663
    }
1664

1665
    c.JSON(http.StatusOK, gin.H{
1666
        "service": serviceName,
1667
        "data":    monthlyData,
1668
    })
1669
}
1670

1671
// calculateUserAggregateStats calculates aggregate statistics for all users
1672
func calculateUserAggregateStats(ctx context.Context, users []models.User, learningService services.LearningServiceInterface, logger *observability.Logger) map[string]interface{} {
1673
    stats := map[string]interface{}{
1674
        "total_users":              len(users),
1675
        "by_language":              make(map[string]int),
1676
        "by_level":                 make(map[string]int),
1677
        "by_ai_provider":           make(map[string]int),
1678
        "by_ai_model":              make(map[string]int),
1679
        "ai_enabled":               0,
1680
        "ai_disabled":              0,
1681
        "active_users":             0,
1682
        "inactive_users":           0,
1683
        "total_questions_answered": 0,
1684
        "total_correct_answers":    0,
1685
        "average_accuracy":         0.0,
1686
    }
1687

1688
    activeThreshold := time.Now().AddDate(0, 0, -7)
1689

1690
    for _, user := range users {
1691
        lang := "unknown"
1692
        if user.PreferredLanguage.Valid {
1693
            lang = user.PreferredLanguage.String
1694
        }
1695
        stats["by_language"].(map[string]int)[lang]++
1696

1697
        level := "unknown"
1698
        if user.CurrentLevel.Valid {
1699
            level = user.CurrentLevel.String
1700
        }
1701
        stats["by_level"].(map[string]int)[level]++
1702

1703
        provider := "none"
1704
        if user.AIProvider.Valid {
1705
            provider = user.AIProvider.String
1706
        }
1707
        stats["by_ai_provider"].(map[string]int)[provider]++
1708

1709
        model := "none"
1710
        if user.AIModel.Valid {
1711
            model = user.AIModel.String
1712
        }
1713
        stats["by_ai_model"].(map[string]int)[model]++
1714

1715
        if user.AIEnabled.Valid && user.AIEnabled.Bool {
1716
            aiEnabled := stats["ai_enabled"].(int)
1717
            stats["ai_enabled"] = aiEnabled + 1
1718
        } else {
1719
            aiDisabled := stats["ai_disabled"].(int)
1720
            stats["ai_disabled"] = aiDisabled + 1
1721
        }
1722

1723
        if user.LastActive.Valid {
1724
            lastActive := user.LastActive.Time
1725
            if lastActive.After(activeThreshold) {
1726
                activeUsers := stats["active_users"].(int)
1727
                stats["active_users"] = activeUsers + 1
1728
            } else {
1729
                inactiveUsers := stats["inactive_users"].(int)
1730
                stats["inactive_users"] = inactiveUsers + 1
1731
            }
1732
        } else {
1733
            inactiveUsers := stats["inactive_users"].(int)
1734
            stats["inactive_users"] = inactiveUsers + 1
1735
        }
1736

1737
        progress, err := learningService.GetUserProgress(ctx, user.ID)
1738
        if err != nil {
1739
            logger.Warn(ctx, "Failed to get progress for user", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
1740
            continue
1741
        }
1742

1743
        if progress != nil {
1744
            totalAnswered := stats["total_questions_answered"].(int)
1745
            stats["total_questions_answered"] = totalAnswered + progress.TotalQuestions
1746

1747
            totalCorrect := stats["total_correct_answers"].(int)
1748
            stats["total_correct_answers"] = totalCorrect + progress.CorrectAnswers
1749
        }
1750
    }
1751

1752
    totalAnswered := stats["total_questions_answered"].(int)
1753
    if totalAnswered > 0 {
1754
        stats["average_accuracy"] = float64(stats["total_correct_answers"].(int)) / float64(totalAnswered) * 100.0
1755
    }
1756

1757
    return stats
1758
}
1759


			
quizapp internal handlers worker_admin_handler.go
59.4%
Statements
38/64
1
package handlers
2

3
import (
4
    "encoding/json"
5
    "fmt"
6
    "strings"
7
)
8

9
// MergeAISuggestion merges AI response into the original question map.
10
// It ensures top-level metadata from original are preserved and AI-provided
11
// content is merged into original["content"].
12
//
13
// Canonical location for `correct_answer` and `explanation` is the TOP LEVEL of
14
// the returned object. Any occurrences under `content` are removed.
15
4x
func MergeAISuggestion(original, aiResp map[string]interface{}) map[string]interface{} {
16
4x
    // copy original to avoid mutating caller's map
17
4x
    out := map[string]interface{}{}
18
4x
    b, _ := json.Marshal(original)
19
4x
    _ = json.Unmarshal(b, &out)
20
4x

21
4x
    // ensure content map exists
22
4x
    contentIface := out["content"]
23
4x
    contentMap, _ := contentIface.(map[string]interface{})
24
4x
    if contentMap == nil {
25
        contentMap = map[string]interface{}{}
26
        out["content"] = contentMap
27
    }
28

29
    // merge ai content into content map
30
4x
    if aiContentRaw, ok := aiResp["content"]; ok {
31
4x
        if aiContentMap, ok2 := aiContentRaw.(map[string]interface{}); ok2 {
32
4x
            for k, v := range aiContentMap {
33
16x
                contentMap[k] = v
34
16x
            }
35
        }
36
    }
37

38
    // Ensure answer/explanation live at TOP LEVEL on the output, not inside content
39
    // Prefer values from the AI response when present.
40
4x
    if ca, ok := aiResp["correct_answer"]; ok {
41
4x
        out["correct_answer"] = ca
42
4x
    }
43
4x
    if ex, ok := aiResp["explanation"]; ok {
44
4x
        out["explanation"] = ex
45
4x
    }
46

47
    // Remove any duplicates that may exist inside content
48
4x
    delete(contentMap, "correct_answer")
49
4x
    delete(contentMap, "explanation")
50
4x

51
4x
    if cr, ok := aiResp["change_reason"]; ok {
52
4x
        out["change_reason"] = cr
53
4x
    }
54

55
4x
    NormalizeContent(contentMap)
56
4x

57
4x
    return out
58
}
59

60
// NormalizeContent attempts to sanitize content fields: options->[]string and
61
// simple string coercions for human-readable fields. Answer/explanation stay at
62
// top level and are not touched here.
63
4x
func NormalizeContent(contentMap map[string]interface{}) {
64
4x
    // normalize options
65
4x
    if optsRaw, ok := contentMap["options"]; ok {
66
4x
        switch opts := optsRaw.(type) {
67
4x
        case []interface{}:
68
4x
            seen := map[string]bool{}
69
4x
            var out []string
70
4x
            for _, it := range opts {
71
14x
                s, ok := it.(string)
72
14x
                if !ok {
73
                    continue
74
                }
75
14x
                s = strings.TrimSpace(s)
76
14x
                if s == "" {
77
                    continue
78
                }
79
14x
                if !seen[s] {
80
14x
                    out = append(out, s)
81
14x
                    seen[s] = true
82
14x
                }
83
            }
84
4x
            contentMap["options"] = out
85
        case []string:
86
            // ok
87
        case string:
88
            var parsed []string
89
            if err := json.Unmarshal([]byte(opts), &parsed); err == nil {
90
                contentMap["options"] = parsed
91
            } else {
92
                parts := strings.FieldsFunc(opts, func(r rune) bool { return r == '\n' || r == ',' })
93
                var out []string
94
                seen := map[string]bool{}
95
                for _, p := range parts {
96
                    p = strings.TrimSpace(p)
97
                    if p == "" {
98
                        continue
99
                    }
100
                    if !seen[p] {
101
                        out = append(out, p)
102
                        seen[p] = true
103
                    }
104
                }
105
                contentMap["options"] = out
106
            }
107
        default:
108
            delete(contentMap, "options")
109
        }
110
    }
111

112
    // ensure options slice is []string
113
4x
    if optsI, ok := contentMap["options"].([]interface{}); ok {
114
        var out []string
115
        for _, it := range optsI {
116
            if s, ok := it.(string); ok {
117
                out = append(out, s)
118
            }
119
        }
120
        contentMap["options"] = out
121
    }
122

123
    // Ensure no stray correct_answer under content
124
4x
    delete(contentMap, "correct_answer")
125
4x

126
4x
    // ensure simple string fields
127
4x
    for _, k := range []string{"explanation", "question", "passage", "sentence"} {
128
16x
        if v, ok := contentMap[k]; ok {
129
4x
            switch t := v.(type) {
130
4x
            case string:
131
                // ok
132
            default:
133
                contentMap[k] = fmt.Sprint(t)
134
            }
135
        }
136
    }
137
}
138


			
quizapp internal handlers worker_admin_handler.go
0.4%
Statements
1/233
1
package handlers
2

3
import (
4
    "net/http"
5
    "strconv"
6
    "strings"
7

8
    "quizapp/internal/api"
9
    "quizapp/internal/config"
10
    "quizapp/internal/observability"
11
    "quizapp/internal/services"
12
    contextutils "quizapp/internal/utils"
13

14
    "github.com/gin-gonic/gin"
15
    "github.com/google/uuid"
16
    "go.opentelemetry.io/otel/attribute"
17
)
18

19
// AIConversationHandler handles AI conversation-related HTTP requests
20
type AIConversationHandler struct {
21
    conversationService services.ConversationServiceInterface
22
    cfg                 *config.Config
23
    logger              *observability.Logger
24
}
25

26
// NewAIConversationHandler creates a new AIConversationHandler
27
func NewAIConversationHandler(
28
    conversationService services.ConversationServiceInterface,
29
    cfg *config.Config,
30
    logger *observability.Logger,
31
14x
) *AIConversationHandler {
32
14x
    return &AIConversationHandler{
33
14x
        conversationService: conversationService,
34
14x
        cfg:                 cfg,
35
14x
        logger:              logger,
36
14x
    }
37
14x
}
38

39
// GetConversations handles GET /v1/ai/conversations
40
func (h *AIConversationHandler) GetConversations(c *gin.Context) {
41
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_ai_conversations")
42
    defer observability.FinishSpan(span, nil)
43

44
    userID, exists := GetUserIDFromSession(c)
45
    if !exists {
46
        HandleAppError(c, contextutils.ErrUnauthorized)
47
        return
48
    }
49

50
    // Parse query parameters
51
    limitStr := c.DefaultQuery("limit", "20")
52
    offsetStr := c.DefaultQuery("offset", "0")
53

54
    limit, err := strconv.Atoi(limitStr)
55
    if err != nil || limit < 1 || limit > 100 {
56
        HandleAppError(c, contextutils.ErrInvalidFormat)
57
        return
58
    }
59

60
    offset, err := strconv.Atoi(offsetStr)
61
    if err != nil || offset < 0 {
62
        HandleAppError(c, contextutils.ErrInvalidFormat)
63
        return
64
    }
65

66
    // Add span attributes for observability
67
    span.SetAttributes(
68
        observability.AttributeUserID(userID),
69
        attribute.Int("limit", limit),
70
        attribute.Int("offset", offset),
71
    )
72

73
    // Get conversations for the user
74
    conversations, total, err := h.conversationService.GetUserConversations(ctx, uint(userID), limit, offset)
75
    if err != nil {
76
        h.logger.Error(ctx, "Failed to get user conversations", err, map[string]interface{}{
77
            "user_id": userID,
78
            "limit":   limit,
79
            "offset":  offset,
80
        })
81
        HandleAppError(c, contextutils.WrapError(err, "failed to get conversations"))
82
        return
83
    }
84

85
    // Enrich with message counts to support UI badges without loading messages
86
    counts, err := h.conversationService.GetUserMessageCounts(ctx, uint(userID))
87
    if err != nil {
88
        h.logger.Error(ctx, "Failed to get message counts", err, map[string]interface{}{
89
            "user_id": userID,
90
        })
91
        // Not fatal; continue without counts
92
        counts = map[string]int{}
93
    }
94

95
    // Inject message_count into each conversation via a response wrapper to keep type safety
96
    type conversationWithCount struct {
97
        api.Conversation
98
        MessageCount int `json:"message_count"`
99
    }
100
    convsWithCount := make([]conversationWithCount, 0, len(conversations))
101
    for _, conv := range conversations {
102
        idStr := conv.Id.String()
103
        convsWithCount = append(convsWithCount, conversationWithCount{
104
            Conversation: conv,
105
            MessageCount: counts[idStr],
106
        })
107
    }
108

109
    // Add total count to response
110
    response := gin.H{
111
        "conversations": convsWithCount,
112
        "total":         total,
113
        "limit":         limit,
114
        "offset":        offset,
115
    }
116

117
    c.JSON(http.StatusOK, response)
118
}
119

120
// CreateConversation handles POST /v1/ai/conversations
121
func (h *AIConversationHandler) CreateConversation(c *gin.Context) {
122
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "create_ai_conversation")
123
    defer observability.FinishSpan(span, nil)
124

125
    userID, exists := GetUserIDFromSession(c)
126
    if !exists {
127
        HandleAppError(c, contextutils.ErrUnauthorized)
128
        return
129
    }
130

131
    // Parse request body
132
    var req api.CreateConversationRequest
133
    if err := c.ShouldBindJSON(&req); err != nil {
134
        HandleAppError(c, contextutils.NewAppErrorWithCause(
135
            contextutils.ErrorCodeInvalidInput,
136
            contextutils.SeverityWarn,
137
            "Invalid request body",
138
            "",
139
            err,
140
        ))
141
        return
142
    }
143

144
    // Add span attributes for observability
145
    span.SetAttributes(
146
        observability.AttributeUserID(userID),
147
        attribute.String("conversation_title", req.Title),
148
    )
149

150
    // Create conversation
151
    conversation, err := h.conversationService.CreateConversation(ctx, uint(userID), &req)
152
    if err != nil {
153
        h.logger.Error(ctx, "Failed to create conversation", err, map[string]interface{}{
154
            "user_id": userID,
155
            "title":   req.Title,
156
        })
157
        HandleAppError(c, contextutils.WrapError(err, "failed to create conversation"))
158
        return
159
    }
160

161
    c.JSON(http.StatusCreated, conversation)
162
}
163

164
// GetConversation handles GET /v1/ai/conversations/{id}
165
func (h *AIConversationHandler) GetConversation(c *gin.Context) {
166
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_ai_conversation")
167
    defer observability.FinishSpan(span, nil)
168

169
    userID, exists := GetUserIDFromSession(c)
170
    if !exists {
171
        HandleAppError(c, contextutils.ErrUnauthorized)
172
        return
173
    }
174

175
    // Parse conversation ID parameter
176
    conversationID := c.Param("id")
177
    if conversationID == "" {
178
        HandleAppError(c, contextutils.ErrMissingRequired)
179
        return
180
    }
181

182
    // Validate UUID format
183
    if _, err := uuid.Parse(conversationID); err != nil {
184
        HandleAppError(c, contextutils.ErrInvalidFormat)
185
        return
186
    }
187

188
    // Add span attributes for observability
189
    span.SetAttributes(
190
        observability.AttributeUserID(userID),
191
        attribute.String("conversation_id", conversationID),
192
    )
193

194
    // Get conversation with messages
195
    conversation, err := h.conversationService.GetConversation(ctx, conversationID, uint(userID))
196
    if err != nil {
197
        h.logger.Error(ctx, "Failed to get conversation", err, map[string]interface{}{
198
            "user_id":         userID,
199
            "conversation_id": conversationID,
200
        })
201

202
        // Check if it's a conversation not found error
203
        if strings.Contains(err.Error(), "conversation not found") {
204
            HandleAppError(c, contextutils.ErrRecordNotFound)
205
            return
206
        }
207

208
        HandleAppError(c, contextutils.WrapError(err, "failed to get conversation"))
209
        return
210
    }
211

212
    c.JSON(http.StatusOK, conversation)
213
}
214

215
// UpdateConversation handles PUT /v1/ai/conversations/{id}
216
func (h *AIConversationHandler) UpdateConversation(c *gin.Context) {
217
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "update_ai_conversation")
218
    defer observability.FinishSpan(span, nil)
219

220
    userID, exists := GetUserIDFromSession(c)
221
    if !exists {
222
        HandleAppError(c, contextutils.ErrUnauthorized)
223
        return
224
    }
225

226
    // Parse conversation ID parameter
227
    conversationID := c.Param("id")
228
    if conversationID == "" {
229
        HandleAppError(c, contextutils.ErrMissingRequired)
230
        return
231
    }
232

233
    // Validate UUID format
234
    if _, err := uuid.Parse(conversationID); err != nil {
235
        HandleAppError(c, contextutils.ErrInvalidFormat)
236
        return
237
    }
238

239
    // Parse request body
240
    var req api.UpdateConversationRequest
241
    if err := c.ShouldBindJSON(&req); err != nil {
242
        HandleAppError(c, contextutils.NewAppErrorWithCause(
243
            contextutils.ErrorCodeInvalidInput,
244
            contextutils.SeverityWarn,
245
            "Invalid request body",
246
            "",
247
            err,
248
        ))
249
        return
250
    }
251

252
    // Add span attributes for observability
253
    span.SetAttributes(
254
        observability.AttributeUserID(userID),
255
        attribute.String("conversation_id", conversationID),
256
        attribute.String("new_title", req.Title),
257
    )
258

259
    // Update conversation
260
    conversation, err := h.conversationService.UpdateConversation(ctx, conversationID, uint(userID), &req)
261
    if err != nil {
262
        h.logger.Error(ctx, "Failed to update conversation", err, map[string]interface{}{
263
            "user_id":         userID,
264
            "conversation_id": conversationID,
265
            "new_title":       req.Title,
266
        })
267

268
        // Check if it's a conversation not found error
269
        if strings.Contains(err.Error(), "conversation not found") {
270
            HandleAppError(c, contextutils.ErrRecordNotFound)
271
            return
272
        }
273

274
        HandleAppError(c, contextutils.WrapError(err, "failed to update conversation"))
275
        return
276
    }
277

278
    c.JSON(http.StatusOK, conversation)
279
}
280

281
// DeleteConversation handles DELETE /v1/ai/conversations/{id}
282
func (h *AIConversationHandler) DeleteConversation(c *gin.Context) {
283
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "delete_ai_conversation")
284
    defer observability.FinishSpan(span, nil)
285

286
    userID, exists := GetUserIDFromSession(c)
287
    if !exists {
288
        HandleAppError(c, contextutils.ErrUnauthorized)
289
        return
290
    }
291

292
    // Parse conversation ID parameter
293
    conversationID := c.Param("id")
294
    if conversationID == "" {
295
        HandleAppError(c, contextutils.ErrMissingRequired)
296
        return
297
    }
298

299
    // Validate UUID format
300
    if _, err := uuid.Parse(conversationID); err != nil {
301
        HandleAppError(c, contextutils.ErrInvalidFormat)
302
        return
303
    }
304

305
    // Add span attributes for observability
306
    span.SetAttributes(
307
        observability.AttributeUserID(userID),
308
        attribute.String("conversation_id", conversationID),
309
    )
310

311
    // Delete conversation and all its messages
312
    err := h.conversationService.DeleteConversation(ctx, conversationID, uint(userID))
313
    if err != nil {
314
        h.logger.Error(ctx, "Failed to delete conversation", err, map[string]interface{}{
315
            "user_id":         userID,
316
            "conversation_id": conversationID,
317
        })
318

319
        // Check if it's a conversation not found error
320
        if strings.Contains(err.Error(), "conversation not found") {
321
            HandleAppError(c, contextutils.ErrRecordNotFound)
322
            return
323
        }
324

325
        HandleAppError(c, contextutils.WrapError(err, "failed to delete conversation"))
326
        return
327
    }
328

329
    c.Status(http.StatusNoContent)
330
}
331

332
// AddMessage handles POST /v1/ai/conversations/{conversationId}/messages
333
func (h *AIConversationHandler) AddMessage(c *gin.Context) {
334
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "add_ai_message")
335
    defer observability.FinishSpan(span, nil)
336

337
    userID, exists := GetUserIDFromSession(c)
338
    if !exists {
339
        HandleAppError(c, contextutils.ErrUnauthorized)
340
        return
341
    }
342

343
    // Parse conversation ID parameter
344
    conversationID := c.Param("conversationId")
345
    if conversationID == "" {
346
        HandleAppError(c, contextutils.ErrMissingRequired)
347
        return
348
    }
349

350
    // Validate UUID format
351
    if _, err := uuid.Parse(conversationID); err != nil {
352
        HandleAppError(c, contextutils.ErrInvalidFormat)
353
        return
354
    }
355

356
    // Parse request body
357
    var req api.CreateMessageRequest
358
    if err := c.ShouldBindJSON(&req); err != nil {
359
        HandleAppError(c, contextutils.NewAppErrorWithCause(
360
            contextutils.ErrorCodeInvalidInput,
361
            contextutils.SeverityWarn,
362
            "Invalid request body",
363
            "",
364
            err,
365
        ))
366
        return
367
    }
368

369
    // Calculate content length for observability
370
    contentLength := 0
371
    if req.Content.Text != nil {
372
        contentLength = len(*req.Content.Text)
373
    }
374

375
    // Add span attributes for observability
376
    span.SetAttributes(
377
        observability.AttributeUserID(userID),
378
        attribute.String("conversation_id", conversationID),
379
        attribute.String("message_role", string(req.Role)),
380
        attribute.Int("message_content_length", contentLength),
381
    )
382

383
    // Add message to conversation
384
    createdMessage, err := h.conversationService.AddMessage(ctx, conversationID, uint(userID), &req)
385
    if err != nil {
386
        h.logger.Error(ctx, "Failed to add message to conversation", err, map[string]interface{}{
387
            "user_id":         userID,
388
            "conversation_id": conversationID,
389
            "message_role":    req.Role,
390
        })
391

392
        // Check if it's a conversation not found error
393
        if strings.Contains(err.Error(), "conversation not found") {
394
            HandleAppError(c, contextutils.ErrRecordNotFound)
395
            return
396
        }
397

398
        HandleAppError(c, contextutils.WrapError(err, "failed to add message"))
399
        return
400
    }
401

402
    c.JSON(http.StatusCreated, createdMessage)
403
}
404

405
// SearchConversations handles GET /v1/ai/search
406
func (h *AIConversationHandler) SearchConversations(c *gin.Context) {
407
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "search_ai_conversations")
408
    defer observability.FinishSpan(span, nil)
409

410
    userID, exists := GetUserIDFromSession(c)
411
    if !exists {
412
        HandleAppError(c, contextutils.ErrUnauthorized)
413
        return
414
    }
415

416
    // Parse query parameters
417
    query := c.Query("q")
418
    if query == "" {
419
        HandleAppError(c, contextutils.ErrInvalidInput)
420
        return
421
    }
422

423
    limitStr := c.DefaultQuery("limit", "20")
424
    offsetStr := c.DefaultQuery("offset", "0")
425

426
    limit, err := strconv.Atoi(limitStr)
427
    if err != nil || limit < 1 || limit > 100 {
428
        HandleAppError(c, contextutils.ErrInvalidFormat)
429
        return
430
    }
431

432
    offset, err := strconv.Atoi(offsetStr)
433
    if err != nil || offset < 0 {
434
        HandleAppError(c, contextutils.ErrInvalidFormat)
435
        return
436
    }
437

438
    // Add span attributes for observability
439
    span.SetAttributes(
440
        observability.AttributeUserID(userID),
441
        attribute.String("search_query", query),
442
        attribute.Int("limit", limit),
443
        attribute.Int("offset", offset),
444
    )
445

446
    // Search conversations
447
    conversations, total, err := h.conversationService.SearchConversations(ctx, uint(userID), query, limit, offset)
448
    if err != nil {
449
        h.logger.Error(ctx, "Failed to search conversations", err, map[string]interface{}{
450
            "user_id": userID,
451
            "query":   query,
452
            "limit":   limit,
453
            "offset":  offset,
454
        })
455
        HandleAppError(c, contextutils.WrapError(err, "failed to search conversations"))
456
        return
457
    }
458

459
    // Add total count to response
460
    response := gin.H{
461
        "conversations": conversations,
462
        "query":         query,
463
        "total":         total,
464
        "limit":         limit,
465
        "offset":        offset,
466
    }
467

468
    c.JSON(http.StatusOK, response)
469
}
470

471
// ToggleMessageBookmark handles PUT /v1/ai/conversations/bookmark
472
func (h *AIConversationHandler) ToggleMessageBookmark(c *gin.Context) {
473
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "toggle_message_bookmark")
474
    defer observability.FinishSpan(span, nil)
475

476
    userID, exists := GetUserIDFromSession(c)
477
    if !exists {
478
        HandleAppError(c, contextutils.ErrUnauthorized)
479
        return
480
    }
481

482
    // Parse request body
483
    var req struct {
484
        ConversationID string `json:"conversation_id" binding:"required"`
485
        MessageID      string `json:"message_id" binding:"required"`
486
    }
487
    if err := c.ShouldBindJSON(&req); err != nil {
488
        HandleAppError(c, contextutils.NewAppErrorWithCause(
489
            contextutils.ErrorCodeInvalidInput,
490
            contextutils.SeverityWarn,
491
            "Invalid request body",
492
            "",
493
            err,
494
        ))
495
        return
496
    }
497

498
    // Validate UUID formats
499
    if _, err := uuid.Parse(req.ConversationID); err != nil {
500
        HandleAppError(c, contextutils.ErrInvalidFormat)
501
        return
502
    }
503
    if _, err := uuid.Parse(req.MessageID); err != nil {
504
        HandleAppError(c, contextutils.ErrInvalidFormat)
505
        return
506
    }
507

508
    // Add span attributes for observability
509
    span.SetAttributes(
510
        observability.AttributeUserID(userID),
511
        attribute.String("conversation_id", req.ConversationID),
512
        attribute.String("message_id", req.MessageID),
513
    )
514

515
    // Toggle message bookmark
516
    newBookmarkedStatus, err := h.conversationService.ToggleMessageBookmark(ctx, req.ConversationID, req.MessageID, uint(userID))
517
    if err != nil {
518
        h.logger.Error(ctx, "Failed to toggle message bookmark", err, map[string]interface{}{
519
            "user_id":         userID,
520
            "conversation_id": req.ConversationID,
521
            "message_id":      req.MessageID,
522
        })
523

524
        // Check if it's a conversation or message not found error
525
        if strings.Contains(err.Error(), "not found") {
526
            HandleAppError(c, contextutils.ErrRecordNotFound)
527
            return
528
        }
529

530
        HandleAppError(c, contextutils.WrapError(err, "failed to toggle message bookmark"))
531
        return
532
    }
533

534
    c.JSON(http.StatusOK, gin.H{
535
        "bookmarked": newBookmarkedStatus,
536
    })
537
}
538

539
// GetBookmarkedMessages handles GET /v1/ai/bookmarks
540
func (h *AIConversationHandler) GetBookmarkedMessages(c *gin.Context) {
541
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_bookmarked_messages")
542
    defer observability.FinishSpan(span, nil)
543

544
    userID, exists := GetUserIDFromSession(c)
545
    if !exists {
546
        HandleAppError(c, contextutils.ErrUnauthorized)
547
        return
548
    }
549

550
    // Parse query parameters
551
    query := c.DefaultQuery("q", "")
552
    limitStr := c.DefaultQuery("limit", "20")
553
    offsetStr := c.DefaultQuery("offset", "0")
554

555
    limit, err := strconv.Atoi(limitStr)
556
    if err != nil || limit < 1 || limit > 100 {
557
        HandleAppError(c, contextutils.ErrInvalidFormat)
558
        return
559
    }
560

561
    offset, err := strconv.Atoi(offsetStr)
562
    if err != nil || offset < 0 {
563
        HandleAppError(c, contextutils.ErrInvalidFormat)
564
        return
565
    }
566

567
    // Add span attributes for observability
568
    span.SetAttributes(
569
        observability.AttributeUserID(userID),
570
        attribute.String("search_query", query),
571
        attribute.Int("limit", limit),
572
        attribute.Int("offset", offset),
573
    )
574

575
    // Get bookmarked messages
576
    messages, total, err := h.conversationService.GetBookmarkedMessages(ctx, uint(userID), query, limit, offset)
577
    if err != nil {
578
        h.logger.Error(ctx, "Failed to get bookmarked messages", err, map[string]interface{}{
579
            "user_id": userID,
580
            "query":   query,
581
            "limit":   limit,
582
            "offset":  offset,
583
        })
584
        HandleAppError(c, contextutils.WrapError(err, "failed to get bookmarked messages"))
585
        return
586
    }
587

588
    // Add total count to response
589
    response := gin.H{
590
        "messages": messages,
591
        "query":    query,
592
        "total":    total,
593
        "limit":    limit,
594
        "offset":   offset,
595
    }
596

597
    c.JSON(http.StatusOK, response)
598
}
599


			
quizapp internal handlers worker_admin_handler.go
1.1%
Statements
1/87
1
package handlers
2

3
import (
4
    "net/http"
5
    "strconv"
6

7
    "quizapp/internal/api"
8
    "quizapp/internal/middleware"
9
    "quizapp/internal/observability"
10
    "quizapp/internal/services"
11
    contextutils "quizapp/internal/utils"
12

13
    "github.com/gin-gonic/gin"
14
    "go.opentelemetry.io/otel/attribute"
15
)
16

17
// AuthAPIKeyHandler handles authentication API key related HTTP requests
18
type AuthAPIKeyHandler struct {
19
    apiKeyService services.AuthAPIKeyServiceInterface
20
    logger        *observability.Logger
21
}
22

23
// NewAuthAPIKeyHandler creates a new AuthAPIKeyHandler instance
24
14x
func NewAuthAPIKeyHandler(apiKeyService services.AuthAPIKeyServiceInterface, logger *observability.Logger) *AuthAPIKeyHandler {
25
14x
    return &AuthAPIKeyHandler{
26
14x
        apiKeyService: apiKeyService,
27
14x
        logger:        logger,
28
14x
    }
29
14x
}
30

31
// CreateAPIKey handles POST /v1/api-keys
32
func (h *AuthAPIKeyHandler) CreateAPIKey(c *gin.Context) {
33
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "CreateAPIKey")
34
    defer observability.FinishSpan(span, nil)
35

36
    // Get user ID from context (set by auth middleware)
37
    userID, exists := c.Get(middleware.UserIDKey)
38
    if !exists {
39
        HandleAppError(c, contextutils.ErrUnauthorized)
40
        return
41
    }
42

43
    userIDInt, ok := userID.(int)
44
    if !ok {
45
        HandleAppError(c, contextutils.ErrInternalError)
46
        return
47
    }
48

49
    span.SetAttributes(attribute.Int("user_id", userIDInt))
50

51
    // Parse request body
52
    var req struct {
53
        KeyName         string `json:"key_name" binding:"required"`
54
        PermissionLevel string `json:"permission_level" binding:"required"`
55
    }
56

57
    if err := c.ShouldBindJSON(&req); err != nil {
58
        HandleAppError(c, contextutils.NewAppErrorWithCause(
59
            contextutils.ErrorCodeInvalidInput,
60
            contextutils.SeverityWarn,
61
            "Invalid request body",
62
            "",
63
            err,
64
        ))
65
        return
66
    }
67

68
    span.SetAttributes(
69
        attribute.String("key_name", req.KeyName),
70
        attribute.String("permission_level", req.PermissionLevel),
71
    )
72

73
    // Create API key
74
    apiKey, rawKey, err := h.apiKeyService.CreateAPIKey(ctx, userIDInt, req.KeyName, req.PermissionLevel)
75
    if err != nil {
76
        h.logger.Error(ctx, "Failed to create API key", err, map[string]interface{}{
77
            "user_id":          userIDInt,
78
            "key_name":         req.KeyName,
79
            "permission_level": req.PermissionLevel,
80
        })
81
        HandleAppError(c, err)
82
        return
83
    }
84

85
    span.SetAttributes(attribute.Int("api_key_id", apiKey.ID))
86

87
    // Return the full key ONCE (this is the only time it will be shown)
88
    c.JSON(http.StatusCreated, gin.H{
89
        "id":               apiKey.ID,
90
        "key_name":         apiKey.KeyName,
91
        "key":              rawKey, // Full key - only shown once!
92
        "key_prefix":       apiKey.KeyPrefix,
93
        "permission_level": apiKey.PermissionLevel,
94
        "created_at":       apiKey.CreatedAt,
95
        "message":          "Save this API key now. You won't be able to see it again!",
96
    })
97
}
98

99
// ListAPIKeys handles GET /v1/api-keys
100
func (h *AuthAPIKeyHandler) ListAPIKeys(c *gin.Context) {
101
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "ListAPIKeys")
102
    defer observability.FinishSpan(span, nil)
103

104
    // Get user ID from context (set by auth middleware)
105
    userID, exists := c.Get(middleware.UserIDKey)
106
    if !exists {
107
        HandleAppError(c, contextutils.ErrUnauthorized)
108
        return
109
    }
110

111
    userIDInt, ok := userID.(int)
112
    if !ok {
113
        HandleAppError(c, contextutils.ErrInternalError)
114
        return
115
    }
116

117
    span.SetAttributes(attribute.Int("user_id", userIDInt))
118

119
    // List API keys
120
    apiKeys, err := h.apiKeyService.ListAPIKeys(ctx, userIDInt)
121
    if err != nil {
122
        h.logger.Error(ctx, "Failed to list API keys", err, map[string]interface{}{"user_id": userIDInt})
123
        HandleAppError(c, err)
124
        return
125
    }
126

127
    span.SetAttributes(attribute.Int("count", len(apiKeys)))
128

129
    // Convert to generated API types to ensure schema-correct serialization
130
    apiSummaries := convertAuthAPIKeysToAPI(apiKeys)
131
    count := len(apiSummaries)
132
    resp := api.APIKeysListResponse{
133
        ApiKeys: &apiSummaries,
134
        Count:   &count,
135
    }
136
    c.JSON(http.StatusOK, resp)
137
}
138

139
// DeleteAPIKey handles DELETE /v1/api-keys/:id
140
func (h *AuthAPIKeyHandler) DeleteAPIKey(c *gin.Context) {
141
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "DeleteAPIKey")
142
    defer observability.FinishSpan(span, nil)
143

144
    // Get user ID from context (set by auth middleware)
145
    userID, exists := c.Get(middleware.UserIDKey)
146
    if !exists {
147
        HandleAppError(c, contextutils.ErrUnauthorized)
148
        return
149
    }
150

151
    userIDInt, ok := userID.(int)
152
    if !ok {
153
        HandleAppError(c, contextutils.ErrInternalError)
154
        return
155
    }
156

157
    // Get key ID from URL parameter
158
    keyIDStr := c.Param("id")
159
    keyID, err := strconv.Atoi(keyIDStr)
160
    if err != nil {
161
        HandleAppError(c, contextutils.NewAppErrorWithCause(
162
            contextutils.ErrorCodeInvalidInput,
163
            contextutils.SeverityWarn,
164
            "Invalid API key ID",
165
            "",
166
            err,
167
        ))
168
        return
169
    }
170

171
    span.SetAttributes(
172
        attribute.Int("user_id", userIDInt),
173
        attribute.Int("key_id", keyID),
174
    )
175

176
    // Delete API key
177
    err = h.apiKeyService.DeleteAPIKey(ctx, userIDInt, keyID)
178
    if err != nil {
179
        h.logger.Error(ctx, "Failed to delete API key", err, map[string]interface{}{
180
            "user_id": userIDInt,
181
            "key_id":  keyID,
182
        })
183
        HandleAppError(c, err)
184
        return
185
    }
186

187
    c.JSON(http.StatusOK, gin.H{
188
        "success": true,
189
        "message": "API key deleted successfully",
190
    })
191
}
192

193
// TestRead handles GET /v1/api-keys/test-read
194
// Requires API key auth (readonly or full). Returns basic info for verification.
195
func (h *AuthAPIKeyHandler) TestRead(c *gin.Context) {
196
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "TestAPIKeyRead")
197
    defer observability.FinishSpan(span, nil)
198

199
    // Extract context set by middleware
200
    userID := c.GetInt(middleware.UserIDKey)
201
    username := c.GetString(middleware.UsernameKey)
202
    apiKeyID := c.GetInt(middleware.APIKeyIDKey)
203

204
    // Fetch permission level using the key id
205
    var permissionLevel string
206
    if apiKeyID != 0 && userID != 0 {
207
        if apiKey, err := h.apiKeyService.GetAPIKeyByID(ctx, userID, apiKeyID); err == nil && apiKey != nil {
208
            permissionLevel = apiKey.PermissionLevel
209
        }
210
    }
211

212
    c.JSON(http.StatusOK, gin.H{
213
        "ok":               true,
214
        "user_id":          userID,
215
        "username":         username,
216
        "permission_level": permissionLevel,
217
        "api_key_id":       apiKeyID,
218
        "method":           c.Request.Method,
219
    })
220
}
221

222
// TestWrite handles POST /v1/api-keys/test-write
223
// Requires API key auth. Middleware enforces permission by HTTP method.
224
func (h *AuthAPIKeyHandler) TestWrite(c *gin.Context) {
225
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "TestAPIKeyWrite")
226
    defer observability.FinishSpan(span, nil)
227

228
    userID := c.GetInt(middleware.UserIDKey)
229
    username := c.GetString(middleware.UsernameKey)
230
    apiKeyID := c.GetInt(middleware.APIKeyIDKey)
231

232
    var permissionLevel string
233
    if apiKeyID != 0 && userID != 0 {
234
        if apiKey, err := h.apiKeyService.GetAPIKeyByID(ctx, userID, apiKeyID); err == nil && apiKey != nil {
235
            permissionLevel = apiKey.PermissionLevel
236
        }
237
    }
238

239
    c.JSON(http.StatusOK, gin.H{
240
        "ok":               true,
241
        "user_id":          userID,
242
        "username":         username,
243
        "permission_level": permissionLevel,
244
        "api_key_id":       apiKeyID,
245
        "method":           c.Request.Method,
246
    })
247
}
248


			
quizapp internal handlers worker_admin_handler.go
73.2%
Statements
213/291
1
package handlers
2

3
import (
4
    "crypto/rand"
5
    "errors"
6
    "net/http"
7
    "regexp"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/api"
12
    "quizapp/internal/config"
13
    "quizapp/internal/middleware"
14
    "quizapp/internal/observability"
15
    "quizapp/internal/services"
16
    contextutils "quizapp/internal/utils"
17

18
    "github.com/gin-contrib/sessions"
19
    "github.com/gin-gonic/gin"
20
    openapi_types "github.com/oapi-codegen/runtime/types"
21
    "go.opentelemetry.io/otel/attribute"
22
)
23

24
// AuthHandler handles authentication related HTTP requests
25
type AuthHandler struct {
26
    userService  services.UserServiceInterface
27
    oauthService *services.OAuthService
28
    config       *config.Config
29
    logger       *observability.Logger
30
}
31

32
// NewAuthHandler creates a new AuthHandler instance
33
56x
func NewAuthHandler(userService services.UserServiceInterface, oauthService *services.OAuthService, cfg *config.Config, logger *observability.Logger) *AuthHandler {
34
56x
    return &AuthHandler{
35
56x
        userService:  userService,
36
56x
        oauthService: oauthService,
37
56x
        config:       cfg,
38
56x
        logger:       logger,
39
56x
    }
40
56x
}
41

42
// Login handles user login requests
43
198x
func (h *AuthHandler) Login(c *gin.Context) {
44
198x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "login")
45
198x
    defer observability.FinishSpan(span, nil)
46
198x

47
198x
    var req api.LoginRequest
48
198x
    if err := c.ShouldBindJSON(&req); err != nil {
49
3x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
50
3x
            contextutils.ErrorCodeInvalidInput,
51
3x
            contextutils.SeverityWarn,
52
3x
            "Invalid request body",
53
3x
            "",
54
3x
            err,
55
3x
        ))
56
3x
        return
57
3x
    }
58

59
    // Set span attributes for observability
60
195x
    span.SetAttributes(
61
195x
        attribute.String("auth.username", req.Username),
62
195x
        attribute.Bool("auth.password_provided", req.Password != ""),
63
195x
    )
64
195x

65
195x
    // Authenticate user against database
66
195x
    user, err := h.userService.AuthenticateUser(c.Request.Context(), req.Username, req.Password)
67
195x
    if err != nil {
68
10x
        h.logger.Error(c.Request.Context(), "Authentication failed for user", err, map[string]interface{}{"username": req.Username})
69
10x
        HandleAppError(c, contextutils.ErrInvalidCredentials)
70
10x
        return
71
10x
    }
72

73
185x
    if user == nil {
74
        HandleAppError(c, contextutils.ErrInvalidCredentials)
75
        return
76
    }
77

78
    // Update span attributes with user info
79
185x
    span.SetAttributes(
80
185x
        attribute.Int("user.id", user.ID),
81
185x
        attribute.String("user.username", user.Username),
82
185x
        attribute.Bool("user.email_provided", user.Email.Valid),
83
185x
        attribute.String("user.language", user.PreferredLanguage.String),
84
185x
        attribute.String("user.level", user.CurrentLevel.String),
85
185x
    )
86
185x

87
185x
    // Update last active
88
185x
    if err := h.userService.UpdateLastActive(c.Request.Context(), user.ID); err != nil {
89
        // Log error but don't fail login
90
        // In production, you'd want proper logging here
91
        h.logger.Warn(c.Request.Context(), "Failed to update last active for user", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
92
    }
93

94
    // Create session
95
185x
    session := sessions.Default(c)
96
185x
    session.Set(middleware.UserIDKey, user.ID)
97
185x
    session.Set(middleware.UsernameKey, user.Username)
98
185x

99
185x
    if err := session.Save(); err != nil {
100
        h.logger.Error(c.Request.Context(), "Failed to save session", err, map[string]interface{}{"error": err.Error()})
101
        HandleAppError(c, contextutils.WrapError(err, "failed to create session"))
102
        return
103
    }
104

105
    // Convert models.User to api.User with proper API key checking
106
185x
    apiUser := convertUserToAPIWithService(c.Request.Context(), user, h.userService)
107
185x

108
185x
    // Return user info (without API key)
109
185x
    c.JSON(http.StatusOK, api.LoginResponse{
110
185x
        Success: boolPtr(true),
111
185x
        Message: stringPtr("Login successful"),
112
185x
        User:    &apiUser,
113
185x
    })
114
}
115

116
// Logout handles user logout requests
117
4x
func (h *AuthHandler) Logout(c *gin.Context) {
118
4x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "logout")
119
4x
    defer observability.FinishSpan(span, nil)
120
4x

121
4x
    // Get user info before clearing session for tracing
122
4x
    session := sessions.Default(c)
123
4x
    userID := session.Get(middleware.UserIDKey)
124
4x
    username := session.Get(middleware.UsernameKey)
125
4x

126
4x
    // Set span attributes
127
4x
    if userID != nil {
128
1x
        span.SetAttributes(attribute.Int("user.id", userID.(int)))
129
1x
    }
130
4x
    if username != nil {
131
        span.SetAttributes(attribute.String("user.username", username.(string)))
132
    }
133

134
4x
    session.Clear()
135
4x

136
4x
    if err := session.Save(); err != nil {
137
        HandleAppError(c, contextutils.WrapError(err, "failed to clear session"))
138
        return
139
    }
140

141
4x
    c.JSON(http.StatusOK, api.SuccessResponse{
142
4x
        Success: true,
143
4x
        Message: stringPtr("Logout successful"),
144
4x
    })
145
}
146

147
// Status returns the current authentication status
148
11x
func (h *AuthHandler) Status(c *gin.Context) {
149
11x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "status")
150
11x
    defer observability.FinishSpan(span, nil)
151
11x

152
11x
    session := sessions.Default(c)
153
11x
    userID := session.Get(middleware.UserIDKey)
154
11x

155
11x
    if userID == nil {
156
6x
        span.SetAttributes(attribute.Bool("auth.authenticated", false))
157
6x
        c.JSON(http.StatusOK, gin.H{
158
6x
            "authenticated": false,
159
6x
            "user":          nil,
160
6x
        })
161
6x
        return
162
6x
    }
163

164
5x
    span.SetAttributes(
165
5x
        attribute.Bool("auth.authenticated", true),
166
5x
        attribute.Int("user.id", userID.(int)),
167
5x
    )
168
5x

169
5x
    user, err := h.userService.GetUserByID(c.Request.Context(), userID.(int))
170
5x
    if err != nil {
171
        h.logger.Error(c.Request.Context(), "Error getting user by ID", err, map[string]interface{}{"user_id": userID.(int)})
172
        HandleAppError(c, contextutils.ErrInternalError)
173
        return
174
    }
175

176
5x
    if user == nil {
177
        // User not found, clear session
178
        session.Clear()
179
        if err := session.Save(); err != nil {
180
            h.logger.Error(c.Request.Context(), "Error saving session", err, map[string]interface{}{"error": err.Error()})
181
        }
182
        span.SetAttributes(attribute.Bool("auth.user_found", false))
183
        c.JSON(http.StatusOK, gin.H{
184
            "authenticated": false,
185
            "user":          nil,
186
        })
187
        return
188
    }
189

190
    // Update span attributes with user info
191
5x
    span.SetAttributes(
192
5x
        attribute.Bool("auth.user_found", true),
193
5x
        attribute.String("user.username", user.Username),
194
5x
        attribute.Bool("user.email_provided", user.Email.Valid),
195
5x
        attribute.String("user.language", user.PreferredLanguage.String),
196
5x
        attribute.String("user.level", user.CurrentLevel.String),
197
5x
        attribute.Bool("user.ai_enabled", user.AIEnabled.Bool),
198
5x
        attribute.String("user.ai_provider", user.AIProvider.String),
199
5x
        attribute.String("user.ai_model", user.AIModel.String),
200
5x
    )
201
5x

202
5x
    // Update last active timestamp
203
5x
    if err := h.userService.UpdateLastActive(c.Request.Context(), user.ID); err != nil {
204
        h.logger.Error(c.Request.Context(), "Error updating last active", err, map[string]interface{}{"user_id": user.ID})
205
        // Don't fail the request for this error
206
    }
207

208
    // Convert models.User to api.User with proper API key checking
209
5x
    apiUser := convertUserToAPIWithService(c.Request.Context(), user, h.userService)
210
5x

211
5x
    c.JSON(http.StatusOK, gin.H{
212
5x
        "authenticated": true,
213
5x
        "user":          &apiUser,
214
5x
    })
215
}
216

217
// Check is a lightweight auth-check endpoint intended for reverse proxy auth_request.
218
// It requires authentication via middleware and returns 204 when authenticated.
219
// Unauthenticated requests are rejected by the RequireAuth middleware with 401.
220
func (h *AuthHandler) Check(c *gin.Context) {
221
    // If we reached here, authentication succeeded in middleware
222
    c.Status(http.StatusNoContent)
223
}
224

225
// Signup handles user registration requests
226
27x
func (h *AuthHandler) Signup(c *gin.Context) {
227
27x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "signup")
228
27x
    defer observability.FinishSpan(span, nil)
229
27x

230
27x
    // Check if signups are disabled
231
27x
    if h.config != nil && h.config.IsSignupDisabled() {
232
1x
        span.SetAttributes(attribute.Bool("auth.signups_disabled", true))
233
1x
        HandleAppError(c, contextutils.ErrForbidden)
234
1x
        return
235
1x
    }
236

237
25x
    span.SetAttributes(attribute.Bool("auth.signups_disabled", false))
238
25x

239
25x
    var req api.UserCreateRequest
240
25x
    if err := c.ShouldBindJSON(&req); err != nil {
241
1x
        if errors.Is(err, openapi_types.ErrValidationEmail) {
242
1x
            HandleAppError(c, contextutils.ErrInvalidInput)
243
1x
            return
244
1x
        }
245
        HandleAppError(c, contextutils.NewAppErrorWithCause(
246
            contextutils.ErrorCodeInvalidInput,
247
            contextutils.SeverityWarn,
248
            "Invalid request body",
249
            "",
250
            err,
251
        ))
252
        return
253
    }
254

255
    // Set span attributes for request data
256
23x
    span.SetAttributes(
257
23x
        attribute.String("signup.username", req.Username),
258
23x
        attribute.Bool("signup.password_provided", req.Password != ""),
259
23x
        attribute.Bool("signup.email_provided", req.Email != nil && *req.Email != ""),
260
23x
        attribute.Bool("signup.language_provided", req.PreferredLanguage != nil && *req.PreferredLanguage != ""),
261
23x
        attribute.Bool("signup.level_provided", req.CurrentLevel != nil && *req.CurrentLevel != ""),
262
23x
        attribute.Bool("signup.timezone_provided", req.Timezone != nil && *req.Timezone != ""),
263
23x
    )
264
23x

265
23x
    // Validate required fields
266
23x
    if req.Username == "" {
267
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
268
1x
        return
269
1x
    }
270

271
21x
    if req.Password == "" {
272
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
273
1x
        return
274
1x
    }
275

276
19x
    if req.Email == nil || *req.Email == "" {
277
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
278
1x
        return
279
1x
    }
280

281
    // Validate username format (3-50 characters, alphanumeric + underscore)
282
17x
    if len(req.Username) < 3 || len(req.Username) > 50 {
283
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
284
1x
        return
285
1x
    }
286

287
15x
    usernameRegex := regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
288
15x
    if !usernameRegex.MatchString(req.Username) {
289
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
290
1x
        return
291
1x
    }
292

293
    // Validate password (minimum 8 characters)
294
13x
    if len(req.Password) < 8 {
295
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
296
1x
        return
297
1x
    }
298

299
    // Validate email format (convert to string)
300
11x
    if !contextutils.IsValidEmail(string(*req.Email)) {
301
        HandleAppError(c, contextutils.ErrInvalidFormat)
302
        return
303
    }
304

305
    // Normalize email to lowercase
306
11x
    email := strings.ToLower(string(*req.Email))
307
11x

308
11x
    h.logger.Info(c.Request.Context(), "Attempting signup for user", map[string]interface{}{"username": req.Username, "email": email})
309
11x

310
11x
    // Check if username already exists
311
11x
    existingUser, err := h.userService.GetUserByUsername(c.Request.Context(), req.Username)
312
11x
    if err != nil {
313
        h.logger.Error(c.Request.Context(), "Error checking username uniqueness", err, map[string]interface{}{"username": req.Username})
314
        HandleAppError(c, contextutils.ErrInternalError)
315
        return
316
    }
317

318
11x
    if existingUser != nil {
319
3x
        span.SetAttributes(attribute.Bool("signup.username_exists", true))
320
3x
        HandleAppError(c, contextutils.ErrRecordExists)
321
3x
        return
322
3x
    }
323

324
    // Check if email already exists
325
8x
    existingUserByEmail, err := h.userService.GetUserByEmail(c.Request.Context(), email)
326
8x
    if err != nil {
327
        h.logger.Error(c.Request.Context(), "Error checking email uniqueness", err, map[string]interface{}{"email": email})
328
        HandleAppError(c, contextutils.ErrInternalError)
329
        return
330
    }
331

332
8x
    if existingUserByEmail != nil {
333
1x
        span.SetAttributes(attribute.Bool("signup.email_exists", true))
334
1x
        HandleAppError(c, contextutils.ErrRecordExists)
335
1x
        return
336
1x
    }
337

338
    // Set default values for optional fields
339
6x
    language := "italian" // Default to first language in the list
340
6x
    if h.config != nil {
341
6x
        // Get available languages from config
342
6x
        languages := h.config.GetLanguages()
343
6x
        if len(languages) > 0 {
344
6x
            language = languages[0]
345
6x
        }
346
    }
347
6x
    if req.PreferredLanguage != nil && *req.PreferredLanguage != "" {
348
3x
        language = *req.PreferredLanguage
349
3x
    }
350

351
    // Choose canonical default level for the selected language (first level in config)
352
6x
    level := ""
353
6x
    levels := []string{}
354
6x
    if h.config != nil {
355
6x
        levels = h.config.GetLevelsForLanguage(language)
356
6x
        if len(levels) > 0 {
357
6x
            level = levels[0]
358
6x
        }
359
    }
360

361
    // If client provided a level, require it to be a canonical code for the language.
362
6x
    if req.CurrentLevel != nil && *req.CurrentLevel != "" {
363
3x
        provided := *req.CurrentLevel
364
3x
        matched := false
365
3x
        for _, l := range levels {
366
7x
            if strings.EqualFold(l, provided) {
367
3x
                level = l
368
3x
                matched = true
369
3x
                break
370
            }
371
        }
372
3x
        if !matched {
373
            HandleAppError(c, contextutils.ErrInvalidFormat)
374
            return
375
        }
376
    }
377

378
6x
    timezone := "UTC" // Default timezone
379
6x
    if req.Timezone != nil && *req.Timezone != "" {
380
3x
        timezone = *req.Timezone
381
3x
    }
382

383
    // Update span attributes with final values
384
6x
    span.SetAttributes(
385
6x
        attribute.String("signup.language", language),
386
6x
        attribute.String("signup.level", level),
387
6x
        attribute.String("signup.timezone", timezone),
388
6x
    )
389
6x

390
6x
    // Create user with email and timezone (no AI settings)
391
6x
    user, err := h.userService.CreateUserWithEmailAndTimezone(c.Request.Context(), req.Username, email, timezone, language, level)
392
6x
    if err != nil {
393
        h.logger.Error(c.Request.Context(), "Error creating user", err, map[string]interface{}{"username": req.Username, "email": email})
394
        HandleAppError(c, contextutils.WrapError(err, "failed to create user account"))
395
        return
396
    }
397

398
    // Now set the password hash
399
6x
    if err := h.userService.UpdateUserPassword(c.Request.Context(), user.ID, req.Password); err != nil {
400
        h.logger.Error(c.Request.Context(), "Error setting user password", err, map[string]interface{}{"user_id": user.ID})
401
        // Try to clean up the user we just created
402
        if deleteErr := h.userService.DeleteUser(c.Request.Context(), user.ID); deleteErr != nil {
403
            h.logger.Error(c.Request.Context(), "Error cleaning up user after password set failure", err, map[string]interface{}{"user_id": user.ID, "error": deleteErr.Error()})
404
        }
405
        HandleAppError(c, contextutils.WrapError(err, "failed to create user account"))
406
        return
407
    }
408

409
    // Update span attributes with created user info
410
6x
    span.SetAttributes(
411
6x
        attribute.Int("user.id", user.ID),
412
6x
        attribute.String("user.username", user.Username),
413
6x
        attribute.String("user.email", email),
414
6x
    )
415
6x

416
6x
    h.logger.Info(c.Request.Context(), "Successfully created user", map[string]interface{}{"username": req.Username, "user_id": user.ID})
417
6x

418
6x
    // Return success response (no session created, no auto-login)
419
6x
    c.JSON(http.StatusCreated, api.SuccessResponse{
420
6x
        Success: true,
421
6x
        Message: stringPtr("Account created successfully. Please log in."),
422
6x
    })
423
}
424

425
// GoogleLogin initiates Google OAuth flow
426
19x
func (h *AuthHandler) GoogleLogin(c *gin.Context) {
427
19x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "google_login")
428
19x
    defer observability.FinishSpan(span, nil)
429
19x

430
19x
    // Generate a state parameter for security
431
19x
    state := generateRandomState()
432
19x

433
19x
    // Get the redirect URI from query parameters
434
19x
    redirectURI := c.Query("redirect_uri")
435
19x

436
19x
    // Set span attributes
437
19x
    span.SetAttributes(
438
19x
        attribute.String("oauth.provider", "google"),
439
19x
        attribute.String("oauth.state", state),
440
19x
        attribute.String("oauth.redirect_uri", redirectURI),
441
19x
    )
442
19x

443
19x
    // Store state and redirect URI in session for verification
444
19x
    session := sessions.Default(c)
445
19x
    session.Set("oauth_state", state)
446
19x
    if redirectURI != "" {
447
1x
        session.Set("oauth_redirect_uri", redirectURI)
448
1x
    }
449
19x
    if err := session.Save(); err != nil {
450
        HandleAppError(c, contextutils.WrapError(err, "failed to save session"))
451
        return
452
    }
453

454
    // Generate Google OAuth URL
455
19x
    authURL := h.oauthService.GetGoogleAuthURL(c.Request.Context(), state)
456
19x

457
19x
    c.JSON(http.StatusOK, gin.H{
458
19x
        "auth_url": authURL,
459
19x
    })
460
}
461

462
// GoogleCallback handles the OAuth callback from Google
463
19x
func (h *AuthHandler) GoogleCallback(c *gin.Context) {
464
19x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "google_callback")
465
19x
    defer observability.FinishSpan(span, nil)
466
19x

467
19x
    // Get the authorization code and state from query parameters
468
19x
    code := c.Query("code")
469
19x
    state := c.Query("state")
470
19x

471
19x
    // Set span attributes
472
19x
    span.SetAttributes(
473
19x
        attribute.String("oauth.provider", "google"),
474
19x
        attribute.Bool("oauth.code_provided", code != ""),
475
19x
        attribute.String("oauth.state", state),
476
19x
    )
477
19x

478
19x
    h.logger.Info(c.Request.Context(), "Google OAuth callback received", map[string]interface{}{"code": code, "state": state})
479
19x

480
19x
    if code == "" {
481
4x
        HandleAppError(c, contextutils.ErrMissingRequired)
482
4x
        return
483
4x
    }
484

485
    // Verify state parameter for OAuth security (CSRF protection)
486
15x
    session := sessions.Default(c)
487
15x
    storedState := session.Get("oauth_state")
488
15x

489
15x
    h.logger.Info(c.Request.Context(), "OAuth state verification", map[string]interface{}{"stored_state": storedState, "received_state": state})
490
15x

491
15x
    // Enforce strict state verification for security
492
15x
    if storedState == nil {
493
5x
        h.logger.Error(c.Request.Context(), "No OAuth state found in session - possible CSRF attack or session issue", nil, map[string]interface{}{"state": state})
494
5x
        span.SetAttributes(attribute.Bool("oauth.state_valid", false))
495
5x
        HandleAppError(c, contextutils.ErrOAuthStateMismatch)
496
5x
        return
497
5x
    }
498

499
10x
    if storedState.(string) != state {
500
4x
        h.logger.Error(c.Request.Context(), "OAuth state mismatch - possible CSRF attack", nil, map[string]interface{}{"stored_state": storedState.(string), "received_state": state})
501
4x
        span.SetAttributes(attribute.Bool("oauth.state_valid", false))
502
4x
        HandleAppError(c, contextutils.ErrOAuthStateMismatch)
503
4x
        return
504
4x
    }
505

506
3x
    span.SetAttributes(attribute.Bool("oauth.state_valid", true))
507
3x
    h.logger.Info(c.Request.Context(), "OAuth state verification successful")
508
3x

509
3x
    // Check if user is already authenticated (prevent duplicate callbacks)
510
3x
    existingUserID := session.Get(middleware.UserIDKey)
511
3x
    if existingUserID != nil {
512
        h.logger.Info(c.Request.Context(), "User already authenticated during OAuth callback", map[string]interface{}{
513
            "user_id": existingUserID.(int),
514
        })
515
        span.SetAttributes(attribute.Bool("oauth.duplicate_callback", true))
516

517
        // Get user information for the response
518
        user, err := h.userService.GetUserByID(c.Request.Context(), existingUserID.(int))
519
        if err != nil {
520
            h.logger.Error(c.Request.Context(), "Error getting user by ID", err, map[string]interface{}{"user_id": existingUserID.(int)})
521
            HandleAppError(c, contextutils.ErrInternalError)
522
            return
523
        }
524

525
        if user == nil {
526
            h.logger.Error(c.Request.Context(), "User not found", nil, map[string]interface{}{"user_id": existingUserID.(int)})
527
            HandleAppError(c, contextutils.ErrInternalError)
528
            return
529
        }
530

531
        // Convert models.User to api.User with proper API key checking
532
        apiUser := convertUserToAPIWithService(c.Request.Context(), user, h.userService)
533

534
        // Return success response for already authenticated user
535
        response := api.LoginResponse{
536
            Success: boolPtr(true),
537
            Message: stringPtr("Already authenticated"),
538
            User:    &apiUser,
539
        }
540
        c.JSON(http.StatusOK, response)
541
        return
542
    }
543

544
    // Get the stored redirect URI from session
545
3x
    storedRedirectURI := session.Get("oauth_redirect_uri")
546
3x
    var redirectURI string
547
3x
    if storedRedirectURI != nil {
548
1x
        redirectURI = storedRedirectURI.(string)
549
1x
    }
550

551
    // Clear the state and redirect URI from session
552
3x
    session.Delete("oauth_state")
553
3x
    session.Delete("oauth_redirect_uri")
554
3x
    if err := session.Save(); err != nil {
555
        h.logger.Error(c.Request.Context(), "Failed to save session", err, map[string]interface{}{"error": err.Error()})
556
        HandleAppError(c, contextutils.WrapError(err, "failed to save session"))
557
        return
558
    }
559

560
    // Authenticate user with Google OAuth
561
3x
    user, err := h.oauthService.AuthenticateGoogleUser(c.Request.Context(), code, h.userService)
562
3x
    if err != nil {
563
1x
        h.logger.Error(c.Request.Context(), "Google OAuth authentication failed", err, map[string]interface{}{"error": err.Error()})
564
1x

565
1x
        // Check if this is a signup disabled error (structured)
566
1x
        if errors.Is(err, services.ErrSignupsDisabled) {
567
1x
            span.SetAttributes(attribute.Bool("oauth.signups_disabled", true))
568
1x
            HandleAppError(c, contextutils.ErrForbidden)
569
1x
            return
570
1x
        }
571

572
        // Provide better error messages to the frontend using structured error checking
573
        errorMessage := "Authentication failed"
574
        if errors.Is(err, services.ErrOAuthCodeAlreadyUsed) {
575
            errorMessage = "This authentication link has already been used. Please try signing in again."
576
        } else if errors.Is(err, services.ErrOAuthClientConfig) {
577
            errorMessage = "OAuth configuration error. Please contact support."
578
        } else if errors.Is(err, services.ErrOAuthInvalidRequest) {
579
            errorMessage = "Invalid authentication request. Please try again."
580
        } else if errors.Is(err, services.ErrOAuthUnauthorized) {
581
            errorMessage = "OAuth client is not authorized. Please contact support."
582
        } else if errors.Is(err, services.ErrOAuthUnsupportedGrant) {
583
            errorMessage = "Unsupported OAuth grant type. Please contact support."
584
        }
585

586
        HandleAppError(c, contextutils.WrapError(err, errorMessage))
587
        return
588
    }
589

590
    // Update span attributes with user info
591
2x
    span.SetAttributes(
592
2x
        attribute.Int("user.id", user.ID),
593
2x
        attribute.String("user.username", user.Username),
594
2x
        attribute.Bool("user.email_provided", user.Email.Valid),
595
2x
        attribute.String("user.language", user.PreferredLanguage.String),
596
2x
        attribute.String("user.level", user.CurrentLevel.String),
597
2x
        attribute.Bool("user.is_new", user.CreatedAt.After(time.Now().Add(-5*time.Minute))), // Rough check if user was just created
598
2x
    )
599
2x

600
2x
    // Update last active
601
2x
    if err := h.userService.UpdateLastActive(c.Request.Context(), user.ID); err != nil {
602
        h.logger.Warn(c.Request.Context(), "Failed to update last active for user", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
603
    }
604

605
    // Create session
606
2x
    session.Set(middleware.UserIDKey, user.ID)
607
2x
    session.Set(middleware.UsernameKey, user.Username)
608
2x

609
2x
    h.logger.Info(c.Request.Context(), "Setting session for user", map[string]interface{}{"user_id": user.ID, "username": user.Username})
610
2x

611
2x
    if err := session.Save(); err != nil {
612
        h.logger.Error(c.Request.Context(), "Failed to save session", err, map[string]interface{}{"error": err.Error()})
613
        HandleAppError(c, contextutils.WrapError(err, "failed to create session"))
614
        return
615
    }
616

617
    // Convert models.User to api.User with proper API key checking
618
2x
    apiUser := convertUserToAPIWithService(c.Request.Context(), user, h.userService)
619
2x

620
2x
    h.logger.Info(c.Request.Context(), "Google OAuth successful for user", map[string]interface{}{"username": user.Username, "user_id": user.ID})
621
2x

622
2x
    // Return user info with redirect URI if available
623
2x
    response := api.LoginResponse{
624
2x
        Success: boolPtr(true),
625
2x
        Message: stringPtr("Google authentication successful"),
626
2x
        User:    &apiUser,
627
2x
    }
628
2x

629
2x
    // Add redirect URI to response if it was stored
630
2x
    if redirectURI != "" {
631
1x
        response.RedirectUri = &redirectURI
632
1x
    }
633

634
2x
    c.JSON(http.StatusOK, response)
635
}
636

637
// generateRandomState generates a cryptographically secure random state parameter for OAuth security
638
1122x
func generateRandomState() string {
639
1122x
    const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
640
1122x
    b := make([]byte, 32)
641
1122x

642
1122x
    // Use crypto/rand for cryptographically secure random generation
643
1122x
    for i := range b {
644
35904x
        // Generate a random byte and map it to charset
645
35904x
        randomByte := make([]byte, 1)
646
35904x
        if _, err := rand.Read(randomByte); err != nil {
647
            // If crypto/rand fails, we have a serious system issue - don't fallback to weaker randomness
648
            panic("Cryptographic random number generation failed: " + err.Error())
649
        }
650
35904x
        b[i] = charset[randomByte[0]%byte(len(charset))]
651
    }
652
1122x
    return string(b)
653
}
654

655
// SignupStatus returns whether signups are enabled or disabled
656
4x
func (h *AuthHandler) SignupStatus(c *gin.Context) {
657
4x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "signup_status")
658
4x
    defer observability.FinishSpan(span, nil)
659
4x

660
4x
    signupsDisabled := false
661
4x
    oauthWhitelistEnabled := false
662
4x
    var allowedDomains []string
663
4x
    var allowedEmails []string
664
4x

665
4x
    if h.config != nil {
666
4x
        signupsDisabled = h.config.IsSignupDisabled()
667
4x
        if h.config.System != nil {
668
3x
            oauthWhitelistEnabled = len(h.config.System.Auth.AllowedDomains) > 0 || len(h.config.System.Auth.AllowedEmails) > 0
669
3x
            allowedDomains = h.config.System.Auth.AllowedDomains
670
3x
            allowedEmails = h.config.System.Auth.AllowedEmails
671
3x
        }
672
    }
673

674
4x
    span.SetAttributes(
675
4x
        attribute.Bool("auth.signups_disabled", signupsDisabled),
676
4x
        attribute.Bool("auth.config_available", h.config != nil),
677
4x
        attribute.Bool("auth.oauth_whitelist_enabled", oauthWhitelistEnabled),
678
4x
    )
679
4x

680
4x
    c.JSON(http.StatusOK, gin.H{
681
4x
        "signups_disabled":        signupsDisabled,
682
4x
        "oauth_whitelist_enabled": oauthWhitelistEnabled,
683
4x
        "allowed_domains":         allowedDomains,
684
4x
        "allowed_emails":          allowedEmails,
685
4x
    })
686
}
687


			
quizapp internal handlers worker_admin_handler.go
86.4%
Statements
19/22
1
package handlers
2

3
import (
4
    "context"
5
    "errors"
6

7
    "quizapp/internal/middleware"
8

9
    "github.com/gin-contrib/sessions"
10
    "github.com/gin-gonic/gin"
11
)
12

13
var (
14
    // ErrUnauthenticated indicates no current user could be determined
15
    ErrUnauthenticated = errors.New("user not authenticated")
16
    // ErrInvalidUserID indicates the stored user identifier is malformed
17
    ErrInvalidUserID = errors.New("invalid user id")
18
    // ErrForbidden indicates the user lacks permissions for the operation
19
    ErrForbidden = errors.New("forbidden")
20
)
21

22
// GetCurrentUserID returns the current authenticated user's ID.
23
// It first checks the Gin context (set by RequireAuth/RequireAdmin),
24
// then falls back to the session store. Returns an error if unauthenticated
25
// or if the stored value is invalid.
26
13x
func GetCurrentUserID(c *gin.Context) (int, error) {
27
13x
    if rawID, exists := c.Get(middleware.UserIDKey); exists {
28
9x
        if id, ok := rawID.(int); ok {
29
7x
            return id, nil
30
7x
        }
31
1x
        return 0, ErrInvalidUserID
32
    }
33

34
    // Fallback to session lookup if context not populated
35
2x
    session := sessions.Default(c)
36
2x
    userID := session.Get(middleware.UserIDKey)
37
2x
    if userID == nil {
38
1x
        return 0, ErrUnauthenticated
39
1x
    }
40
1x
    id, ok := userID.(int)
41
1x
    if !ok {
42
        return 0, ErrInvalidUserID
43
    }
44
1x
    return id, nil
45
}
46

47
// authzAdminChecker is the minimal capability needed from user service for admin checks.
48
// Any concrete user service that implements IsAdmin satisfies this interface.
49
type authzAdminChecker interface {
50
    IsAdmin(ctx context.Context, userID int) (bool, error)
51
}
52

53
// RequireSelfOrAdmin permits the action if the current user is the target user
54
// or has admin privileges. Returns ErrForbidden when neither condition is met.
55
9x
func RequireSelfOrAdmin(ctx context.Context, svc authzAdminChecker, currentID, targetID int) error {
56
9x
    if currentID == 0 {
57
        return ErrUnauthenticated
58
    }
59
9x
    if currentID == targetID {
60
4x
        return nil
61
4x
    }
62

63
5x
    isAdmin, err := svc.IsAdmin(ctx, currentID)
64
5x
    if err != nil {
65
        return err
66
    }
67
5x
    if !isAdmin {
68
1x
        return ErrForbidden
69
1x
    }
70
3x
    return nil
71
}
72


			
quizapp internal handlers worker_admin_handler.go
85.1%
Statements
298/350
1
package handlers
2

3
import (
4
    "context"
5
    "encoding/json"
6
    "fmt"
7
    "strings"
8
    "time"
9

10
    "quizapp/internal/api"
11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    "quizapp/internal/services"
14
    contextutils "quizapp/internal/utils"
15

16
    openapi_types "github.com/oapi-codegen/runtime/types"
17
    "go.opentelemetry.io/otel/attribute"
18
    "go.opentelemetry.io/otel/trace"
19
)
20

21
// Helper functions for pointer conversion
22
559x
func stringPtr(s string) *string {
23
559x
    return &s
24
559x
}
25

26
201x
func boolPtr(b bool) *bool {
27
201x
    return &b
28
201x
}
29

30
365x
func int64Ptr(i int) *int64 {
31
365x
    i64 := int64(i)
32
365x
    return &i64
33
365x
}
34

35
173x
func float32Ptr(f float32) *float32 {
36
173x
    return &f
37
173x
}
38

39
224x
func intPtr(i int) *int {
40
224x
    return &i
41
224x
}
42

43
56x
func int64FromUint(u uint) *int64 {
44
56x
    i64 := int64(u)
45
56x
    return &i64
46
56x
}
47

48
48x
func timePtr(t time.Time) *time.Time {
49
48x
    return &t
50
48x
}
51

52
// formatTimePtr formats a time.Time into an RFC3339 string pointer
53
202x
func formatTimePtr(t time.Time) *string {
54
202x
    s := t.In(time.UTC).Format(time.RFC3339)
55
202x
    return &s
56
202x
}
57

58
// formatTimePointer converts a *time.Time to *string (RFC3339) or nil
59
202x
func formatTimePointer(tp *time.Time) *string {
60
202x
    if tp == nil {
61
18x
        return nil
62
18x
    }
63
184x
    s := tp.In(time.UTC).Format(time.RFC3339)
64
184x
    return &s
65
}
66

67
// formatTime formats a time.Time into an RFC3339 string
68
147x
func formatTime(t time.Time) string {
69
147x
    return t.In(time.UTC).Format(time.RFC3339)
70
147x
}
71

72
// Convert models.AuthAPIKey to api.APIKeySummary
73
2x
func convertAuthAPIKeyToAPI(key *models.AuthAPIKey) api.APIKeySummary {
74
2x
    apiKey := api.APIKeySummary{}
75
2x

76
2x
    // Scalars
77
2x
    if key.ID != 0 {
78
2x
        apiKey.Id = intPtr(key.ID)
79
2x
    }
80
2x
    if key.KeyName != "" {
81
2x
        apiKey.KeyName = stringPtr(key.KeyName)
82
2x
    }
83
2x
    if key.KeyPrefix != "" {
84
2x
        apiKey.KeyPrefix = stringPtr(key.KeyPrefix)
85
2x
    }
86
2x
    if key.PermissionLevel != "" {
87
2x
        pl := api.APIKeySummaryPermissionLevel(key.PermissionLevel)
88
2x
        apiKey.PermissionLevel = &pl
89
2x
    }
90

91
    // Times
92
2x
    if !key.CreatedAt.IsZero() {
93
2x
        t := key.CreatedAt
94
2x
        apiKey.CreatedAt = &t
95
2x
    }
96
2x
    if !key.UpdatedAt.IsZero() {
97
2x
        t := key.UpdatedAt
98
2x
        apiKey.UpdatedAt = &t
99
2x
    }
100
2x
    if key.LastUsedAt.Valid {
101
1x
        t := key.LastUsedAt.Time
102
1x
        apiKey.LastUsedAt = &t
103
1x
    } else {
104
1x
        // Leave nil to represent null
105
1x
        apiKey.LastUsedAt = nil
106
1x
    }
107

108
2x
    return apiKey
109
}
110

111
// Convert slice of models.AuthAPIKey to []api.APIKeySummary
112
func convertAuthAPIKeysToAPI(keys []models.AuthAPIKey) []api.APIKeySummary {
113
    if len(keys) == 0 {
114
        return []api.APIKeySummary{}
115
    }
116
    out := make([]api.APIKeySummary, 0, len(keys))
117
    for i := range keys {
118
        out = append(out, convertAuthAPIKeyToAPI(&keys[i]))
119
    }
120
    return out
121
}
122

123
// Convert models.User to api.User
124
194x
func convertUserToAPI(user *models.User) api.User {
125
194x
    apiUser := api.User{
126
194x
        Id:       int64Ptr(user.ID),
127
194x
        Username: stringPtr(user.Username),
128
194x
    }
129
194x

130
194x
    if !user.CreatedAt.IsZero() {
131
184x
        apiUser.CreatedAt = formatTimePtr(user.CreatedAt)
132
184x
    }
133

134
194x
    if user.LastActive.Valid {
135
184x
        apiUser.LastActive = formatTimePointer(&user.LastActive.Time)
136
184x
    }
137

138
194x
    if user.Email.Valid {
139
94x
        s := user.Email.String
140
94x
        apiUser.Email = &s
141
94x
    }
142

143
194x
    if user.Timezone.Valid {
144
184x
        s := user.Timezone.String
145
184x
        apiUser.Timezone = &s
146
184x
    }
147

148
194x
    if user.PreferredLanguage.Valid {
149
192x
        s := user.PreferredLanguage.String
150
192x
        apiUser.PreferredLanguage = &s
151
192x
    }
152

153
194x
    if user.CurrentLevel.Valid {
154
192x
        s := user.CurrentLevel.String
155
192x
        apiUser.CurrentLevel = &s
156
192x
    }
157

158
194x
    if user.AIProvider.Valid {
159
86x
        s := user.AIProvider.String
160
86x
        apiUser.AiProvider = &s
161
86x
    }
162

163
194x
    if user.AIModel.Valid {
164
86x
        s := user.AIModel.String
165
86x
        apiUser.AiModel = &s
166
86x
    }
167

168
194x
    if user.WordOfDayEmailEnabled.Valid {
169
184x
        enabled := user.WordOfDayEmailEnabled.Bool
170
184x
        apiUser.WordOfDayEmailEnabled = &enabled
171
184x
    }
172

173
    // Always set ai_enabled as a boolean (never null)
174
194x
    aiEnabled := user.AIEnabled.Valid && user.AIEnabled.Bool
175
194x
    apiUser.AiEnabled = &aiEnabled
176
194x

177
194x
    // For backwards compatibility, we'll set has_api_key to false here
178
194x
    // The proper check should be done using convertUserToAPIWithService
179
194x
    hasAPIKey := false
180
194x
    apiUser.HasApiKey = &hasAPIKey
181
194x

182
194x
    // Include user roles if they exist
183
194x
    if len(user.Roles) > 0 {
184
1x
        apiRoles := make([]api.Role, len(user.Roles))
185
1x
        for i, role := range user.Roles {
186
1x
            apiRoles[i] = api.Role{
187
1x
                Id:          int64(role.ID),
188
1x
                Name:        role.Name,
189
1x
                Description: role.Description,
190
1x
                CreatedAt:   formatTime(role.CreatedAt),
191
1x
                UpdatedAt:   formatTime(role.UpdatedAt),
192
1x
            }
193
1x
        }
194
1x
        apiUser.Roles = &apiRoles
195
    }
196

197
194x
    return apiUser
198
}
199

200
// convertUserToAPIWithService converts a models.User to api.User with proper API key checking
201
194x
func convertUserToAPIWithService(ctx context.Context, user *models.User, userService services.UserServiceInterface) api.User {
202
194x
    apiUser := convertUserToAPI(user)
203
194x

204
194x
    // Check if user has a valid API key for their current provider using the new table
205
194x
    hasAPIKey := false
206
194x
    if user.AIProvider.Valid && user.AIProvider.String != "" {
207
86x
        // Use the new per-provider API key system instead of the old user.AIAPIKey field
208
86x
        if userService != nil {
209
86x
            savedKey, err := userService.GetUserAPIKey(ctx, user.ID, user.AIProvider.String)
210
86x
            if err == nil && savedKey != "" {
211
                // API key is available but not exposed in the API response for security
212
                hasAPIKey = true
213
            }
214
        }
215
    }
216
    // If user doesn't have an AI provider set, hasAPIKey remains false (default)
217
194x
    apiUser.HasApiKey = &hasAPIKey
218
194x

219
194x
    return apiUser
220
}
221

222
// Convert models.Question to api.Question
223
145x
func convertQuestionToAPI(ctx context.Context, question *models.Question) (api.Question, error) {
224
145x
    _, span := observability.TraceFunction(ctx, "handlers", "convert_question_to_api")
225
145x
    defer observability.FinishSpan(span, nil)
226
145x

227
145x
    span.SetAttributes(
228
145x
        attribute.Int64("question.id", int64(question.ID)),
229
145x
        attribute.String("question.type", string(question.Type)),
230
145x
        attribute.String("question.language", question.Language),
231
145x
        attribute.String("question.level", question.Level),
232
145x
    )
233
145x

234
145x
    apiQuestion := api.Question{
235
145x
        Id:              int64Ptr(question.ID),
236
145x
        DifficultyScore: float32Ptr(float32(question.DifficultyScore)),
237
145x
        CorrectAnswer:   intPtr(question.CorrectAnswer),
238
145x
        // UsageCount removed; use total_responses instead
239
145x
    }
240
145x

241
145x
    if !question.CreatedAt.IsZero() {
242
145x
        v := formatTime(question.CreatedAt)
243
145x
        apiQuestion.CreatedAt = &v
244
145x
    }
245

246
145x
    if question.Type != "" {
247
145x
        qType := api.QuestionType(question.Type)
248
145x
        apiQuestion.Type = &qType
249
145x
    }
250

251
145x
    if question.Language != "" {
252
145x
        lang := api.Language(question.Language)
253
145x
        apiQuestion.Language = &lang
254
145x
    }
255

256
145x
    if question.Level != "" {
257
145x
        level := api.Level(question.Level)
258
145x
        apiQuestion.Level = &level
259
145x
    }
260

261
145x
    if question.Explanation != "" {
262
145x
        apiQuestion.Explanation = &question.Explanation
263
145x
    }
264

265
145x
    if question.Status != "" {
266
145x
        status := api.QuestionStatus(question.Status)
267
145x
        apiQuestion.Status = &status
268
145x
    }
269

270
    // Convert content map to api.QuestionContent
271
145x
    if question.Content != nil {
272
145x
        content := &api.QuestionContent{}
273
145x

274
145x
        questionText, options := contextutils.ExtractQuestionContent(question.Content)
275
145x

276
145x
        if questionText != "" {
277
145x
            content.Question = &questionText
278
145x
        }
279

280
        // Extract other optional fields
281
145x
        nestedContent, _ := question.Content["content"].(map[string]interface{})
282
145x
        getString := func(key string) string {
283
435x
            if v, ok := question.Content[key].(string); ok && strings.TrimSpace(v) != "" {
284
                return v
285
            }
286
435x
            if nestedContent != nil {
287
3x
                if v, ok := nestedContent[key].(string); ok && strings.TrimSpace(v) != "" {
288
                    return v
289
                }
290
            }
291
435x
            return ""
292
        }
293

294
145x
        if hint := getString("hint"); hint != "" {
295
            content.Hint = &hint
296
        }
297
145x
        if passage := getString("passage"); passage != "" {
298
            content.Passage = &passage
299
        }
300
145x
        if sentence := getString("sentence"); sentence != "" {
301
            content.Sentence = &sentence
302
        }
303

304
145x
        if len(options) > 0 {
305
145x
            content.Options = options
306
145x
        }
307

308
        // Add tracing for content validation
309
145x
        span.SetAttributes(
310
145x
            attribute.Bool("content.present", true),
311
145x
            attribute.String("content.question", questionText),
312
145x
            attribute.Int("content.options.count", len(options)),
313
145x
            attribute.Bool("content.valid", questionText != "" && len(options) >= 4),
314
145x
        )
315
145x

316
145x
        // Validate required fields using shared helper
317
145x
        if err := contextutils.ValidateQuestionContent(question.Content, question.ID); err != nil {
318
            // Add event for invalid content
319
            span.AddEvent("invalid_content_detected", trace.WithAttributes(
320
                attribute.Bool("question_text_empty", questionText == ""),
321
                attribute.Int("options_count", len(options)),
322
                attribute.Bool("options_insufficient", len(options) < 4),
323
            ))
324

325
            return apiQuestion, err
326
        }
327

328
145x
        apiQuestion.Content = content
329
    } else {
330
        span.SetAttributes(
331
            attribute.Bool("content.present", false),
332
        )
333
        return apiQuestion, contextutils.ErrorWithContextf(
334
            "question %d has nil content",
335
            question.ID,
336
        )
337
    }
338

339
    // Add variety elements to the API response
340
145x
    if question.TopicCategory != "" {
341
142x
        apiQuestion.TopicCategory = &question.TopicCategory
342
142x
    }
343
145x
    if question.GrammarFocus != "" {
344
141x
        apiQuestion.GrammarFocus = &question.GrammarFocus
345
141x
    }
346
145x
    if question.VocabularyDomain != "" {
347
141x
        apiQuestion.VocabularyDomain = &question.VocabularyDomain
348
141x
    }
349
145x
    if question.Scenario != "" {
350
141x
        apiQuestion.Scenario = &question.Scenario
351
141x
    }
352
145x
    if question.StyleModifier != "" {
353
141x
        apiQuestion.StyleModifier = &question.StyleModifier
354
141x
    }
355
145x
    if question.DifficultyModifier != "" {
356
141x
        apiQuestion.DifficultyModifier = &question.DifficultyModifier
357
141x
    }
358
145x
    if question.TimeContext != "" {
359
141x
        apiQuestion.TimeContext = &question.TimeContext
360
141x
    }
361

362
145x
    return apiQuestion, nil
363
}
364

365
// Convert services.QuestionWithStats to a JSON-compatible map using generated
366
// api.Question for fields, and include any additional fields the frontend
367
// expects (e.g., report_reasons) that are not present on the generated type.
368
1x
func convertQuestionWithStatsToAPIMap(ctx context.Context, q *services.QuestionWithStats) (map[string]interface{}, error) {
369
1x
    apiQ := api.Question{}
370
1x
    if q != nil && q.Question != nil {
371
1x
        var err error
372
1x
        apiQ, err = convertQuestionToAPI(ctx, q.Question)
373
1x
        if err != nil {
374
            return nil, err
375
        }
376
    }
377

378
    // Attach stats
379
1x
    if q != nil {
380
1x
        apiQ.CorrectCount = intPtr(q.CorrectCount)
381
1x
        apiQ.IncorrectCount = intPtr(q.IncorrectCount)
382
1x
        apiQ.TotalResponses = intPtr(q.TotalResponses)
383
1x
        apiQ.UserCount = intPtr(q.UserCount)
384
1x
        if q.Reporters != "" {
385
1x
            apiQ.Reporters = &q.Reporters
386
1x
        }
387
        // ConfidenceLevel is optional
388
1x
        if q.ConfidenceLevel != nil {
389
            apiQ.ConfidenceLevel = q.ConfidenceLevel
390
        }
391
    }
392

393
    // Marshal to generic map so we can add fields not present in api.Question
394
1x
    m := map[string]interface{}{}
395
1x
    if b, err := json.Marshal(apiQ); err == nil {
396
1x
        _ = json.Unmarshal(b, &m)
397
1x
    }
398

399
    // Add report_reasons if available on the service struct
400
1x
    if q != nil && q.ReportReasons != "" {
401
1x
        m["report_reasons"] = q.ReportReasons
402
1x
    }
403

404
1x
    return m, nil
405
}
406

407
// Convert models.UserProgress to api.UserProgress
408
18x
func convertUserProgressToAPI(ctx context.Context, progress *models.UserProgress, userID int, userLookup func(context.Context, int) (*models.User, error)) api.UserProgress {
409
18x
    apiProgress := api.UserProgress{
410
18x
        TotalQuestions: intPtr(progress.TotalQuestions),
411
18x
        CorrectAnswers: intPtr(progress.CorrectAnswers),
412
18x
        AccuracyRate:   float32Ptr(float32(progress.AccuracyRate / 100.0)),
413
18x
    }
414
18x

415
18x
    if progress.CurrentLevel != "" {
416
18x
        level := api.Level(progress.CurrentLevel)
417
18x
        apiProgress.CurrentLevel = &level
418
18x
    }
419

420
18x
    if progress.SuggestedLevel != "" {
421
        level := api.Level(progress.SuggestedLevel)
422
        apiProgress.SuggestedLevel = &level
423
    }
424

425
18x
    if progress.WeakAreas != nil {
426
4x
        apiProgress.WeakAreas = &progress.WeakAreas
427
4x
    }
428

429
    // Convert performance metrics
430
18x
    if progress.PerformanceByTopic != nil {
431
18x
        perfMap := make(map[string]api.PerformanceMetrics)
432
18x
        for topic, metrics := range progress.PerformanceByTopic {
433
10x
            if metrics != nil {
434
10x
                perfMap[topic] = api.PerformanceMetrics{
435
10x
                    TotalAttempts:         intPtr(metrics.TotalAttempts),
436
10x
                    CorrectAttempts:       intPtr(metrics.CorrectAttempts),
437
10x
                    AverageResponseTimeMs: float32Ptr(float32(metrics.AverageResponseTimeMs)),
438
10x
                    LastUpdated: func() *string {
439
10x
                        if metrics.LastUpdated.IsZero() {
440
                            return nil
441
                        }
442
10x
                        s, _, err := contextutils.FormatTimeInUserTimezone(ctx, userID, metrics.LastUpdated, time.RFC3339, userLookup)
443
10x
                        if err != nil || s == "" {
444
                            tmp := metrics.LastUpdated.In(time.UTC).Format(time.RFC3339)
445
                            return &tmp
446
                        }
447
10x
                        return &s
448
                    }(),
449
                }
450
            }
451
        }
452
18x
        apiProgress.PerformanceByTopic = &perfMap
453
    }
454

455
    // Convert recent activity
456
18x
    if progress.RecentActivity != nil {
457
10x
        var recentActivity []api.UserResponse
458
10x
        for _, activity := range progress.RecentActivity {
459
26x
            apiActivity := api.UserResponse{
460
26x
                QuestionId: int64Ptr(activity.QuestionID),
461
26x
                IsCorrect:  &activity.IsCorrect,
462
26x
            }
463
26x
            if !activity.CreatedAt.IsZero() {
464
26x
                s, _, err := contextutils.FormatTimeInUserTimezone(ctx, userID, activity.CreatedAt, time.RFC3339, userLookup)
465
26x
                if err != nil || s == "" {
466
                    tmp := activity.CreatedAt.In(time.UTC).Format(time.RFC3339)
467
                    apiActivity.CreatedAt = &tmp
468
                } else {
469
26x
                    apiActivity.CreatedAt = &s
470
26x
                }
471
            }
472
26x
            recentActivity = append(recentActivity, apiActivity)
473
        }
474
10x
        apiProgress.RecentActivity = &recentActivity
475
    }
476

477
18x
    return apiProgress
478
}
479

480
// Convert models.DailyQuestionAssignmentWithQuestion to api.DailyQuestionWithDetails
481
140x
func convertDailyAssignmentToAPI(ctx context.Context, assignment *models.DailyQuestionAssignmentWithQuestion, userID int, userLookup func(context.Context, int) (*models.User, error)) (api.DailyQuestionWithDetails, error) {
482
140x
    var completedAt *string
483
140x
    if assignment.CompletedAt.Valid {
484
7x
        if s, _, err := contextutils.FormatTimeInUserTimezone(ctx, userID, assignment.CompletedAt.Time, time.RFC3339, userLookup); err == nil && s != "" {
485
7x
            completedAt = &s
486
7x
        } else {
487
            tmp := assignment.CompletedAt.Time.In(time.UTC).Format(time.RFC3339)
488
            completedAt = &tmp
489
        }
490
    }
491

492
140x
    apiQuestion := api.Question{}
493
140x
    if assignment.Question != nil {
494
140x
        var err error
495
140x
        apiQuestion, err = convertQuestionToAPI(ctx, assignment.Question)
496
140x
        if err != nil {
497
            return api.DailyQuestionWithDetails{}, err
498
        }
499
        // Override total_responses so UI 'Shown' reflects Daily-only impressions
500
140x
        if assignment.DailyShownCount > 0 {
501
140x
            apiQuestion.TotalResponses = &assignment.DailyShownCount
502
140x
        }
503
    }
504

505
    // AssignmentDate: produce date-only value (YYYY-MM-DD) using openapi_types.Date
506
140x
    ad := assignment.AssignmentDate
507
140x
    assignDate := openapi_types.Date{Time: ad}
508
140x

509
140x
    // CreatedAt in user's timezone (with error-checked fallback)
510
140x
    var createdStr string
511
140x
    if s, _, err := contextutils.FormatTimeInUserTimezone(ctx, userID, assignment.CreatedAt, time.RFC3339, userLookup); err == nil && s != "" {
512
140x
        createdStr = s
513
140x
    } else {
514
        createdStr = assignment.CreatedAt.In(time.UTC).Format(time.RFC3339)
515
    }
516

517
140x
    var submittedAt *string
518
140x
    if assignment.SubmittedAt != nil {
519
7x
        if s, _, err := contextutils.FormatTimeInUserTimezone(ctx, userID, *assignment.SubmittedAt, time.RFC3339, userLookup); err == nil && s != "" {
520
7x
            submittedAt = &s
521
7x
        } else {
522
            tmp := assignment.SubmittedAt.In(time.UTC).Format(time.RFC3339)
523
            submittedAt = &tmp
524
        }
525
    }
526

527
140x
    result := api.DailyQuestionWithDetails{
528
140x
        Id:              int64(assignment.ID),
529
140x
        UserId:          int64(assignment.UserID),
530
140x
        QuestionId:      int64(assignment.QuestionID),
531
140x
        AssignmentDate:  assignDate,
532
140x
        IsCompleted:     assignment.IsCompleted,
533
140x
        CompletedAt:     completedAt,
534
140x
        CreatedAt:       createdStr,
535
140x
        UserAnswerIndex: assignment.UserAnswerIndex,
536
140x
        SubmittedAt:     submittedAt,
537
140x
        Question:        apiQuestion,
538
140x
    }
539
140x

540
140x
    // Attach per-user stats when available
541
140x
    if assignment.DailyShownCount >= 0 {
542
140x
        shown := int64(assignment.DailyShownCount)
543
140x
        result.UserShownCount = &shown
544
140x
    }
545
140x
    if assignment.UserTotalResponses >= 0 {
546
140x
        total := int64(assignment.UserTotalResponses)
547
140x
        result.UserTotalResponses = &total
548
140x
    }
549
140x
    if assignment.UserCorrectCount >= 0 {
550
140x
        cc := int64(assignment.UserCorrectCount)
551
140x
        result.UserCorrectCount = &cc
552
140x
    }
553
140x
    if assignment.UserIncorrectCount >= 0 {
554
140x
        ic := int64(assignment.UserIncorrectCount)
555
140x
        result.UserIncorrectCount = &ic
556
140x
    }
557

558
140x
    return result, nil
559
}
560

561
// Convert slice of assignments
562
14x
func convertDailyAssignmentsToAPI(ctx context.Context, assignments []*models.DailyQuestionAssignmentWithQuestion, userID int, userLookup func(context.Context, int) (*models.User, error)) ([]api.DailyQuestionWithDetails, error) {
563
14x
    _, span := observability.TraceFunction(ctx, "handlers", "convert_daily_assignments_to_api")
564
14x
    defer observability.FinishSpan(span, nil)
565
14x

566
14x
    span.SetAttributes(
567
14x
        attribute.Int("assignments.count", len(assignments)),
568
14x
    )
569
14x

570
14x
    if len(assignments) == 0 {
571
        return []api.DailyQuestionWithDetails{}, nil
572
    }
573
14x
    apiAssignments := make([]api.DailyQuestionWithDetails, len(assignments))
574
14x
    invalidQuestions := []int64{}
575
14x
    invalidQuestionErrors := []string{}
576
14x

577
14x
    for i, a := range assignments {
578
140x
        apiAssignment, err := convertDailyAssignmentToAPI(ctx, a, userID, userLookup)
579
140x
        if err != nil {
580
            // Add detailed tracing for invalid questions
581
            questionID := int64(0)
582
            if a.Question != nil {
583
                questionID = int64(a.Question.ID)
584
            }
585
            invalidQuestions = append(invalidQuestions, questionID)
586
            invalidQuestionErrors = append(invalidQuestionErrors, fmt.Sprintf("question %d: %s", questionID, err.Error()))
587

588
            span.AddEvent("invalid_question_detected", trace.WithAttributes(
589
                attribute.Int64("question.id", questionID),
590
                attribute.Int("assignment.index", i),
591
                attribute.Int64("assignment.id", int64(a.ID)),
592
                attribute.String("error", err.Error()),
593
            ))
594
            continue
595
        }
596

597
140x
        apiAssignments[i] = apiAssignment
598
    }
599

600
    // If any questions are invalid, return an error with detailed information
601
14x
    if len(invalidQuestions) > 0 {
602
        span.SetAttributes(
603
            attribute.Int("invalid_questions.count", len(invalidQuestions)),
604
        )
605
        errorDetails := strings.Join(invalidQuestionErrors, "; ")
606
        return nil, contextutils.ErrorWithContextf(
607
            "found %d question(s) with invalid content. Question IDs: %v. Details: %s",
608
            len(invalidQuestions),
609
            invalidQuestions,
610
            errorDetails,
611
        )
612
    }
613

614
14x
    span.SetAttributes(
615
14x
        attribute.Int("valid_questions.count", len(apiAssignments)),
616
14x
    )
617
14x

618
14x
    return apiAssignments, nil
619
}
620

621
// Convert models.DailyProgress to api.DailyProgress
622
4x
func convertDailyProgressToAPI(progress *models.DailyProgress) api.DailyProgress {
623
4x
    return api.DailyProgress{
624
4x
        Date:      openapi_types.Date{Time: progress.Date},
625
4x
        Completed: progress.Completed,
626
4x
        Total:     progress.Total,
627
4x
    }
628
4x
}
629

630
// Convert models.Story to api.Story
631
14x
func convertStoryToAPI(story *models.Story) api.Story {
632
14x
    apiStory := api.Story{
633
14x
        Id:       int64FromUint(story.ID),
634
14x
        UserId:   int64FromUint(story.UserID),
635
14x
        Title:    stringPtr(story.Title),
636
14x
        Language: stringPtr(story.Language),
637
14x
        Status:   (*api.StoryStatus)(stringPtr(string(story.Status))),
638
14x
    }
639
14x

640
14x
    if story.Subject != nil {
641
6x
        apiStory.Subject = story.Subject
642
6x
    }
643
14x
    if story.AuthorStyle != nil {
644
        apiStory.AuthorStyle = story.AuthorStyle
645
    }
646
14x
    if story.TimePeriod != nil {
647
        apiStory.TimePeriod = story.TimePeriod
648
    }
649
14x
    if story.Genre != nil {
650
        apiStory.Genre = story.Genre
651
    }
652
14x
    if story.Tone != nil {
653
        apiStory.Tone = story.Tone
654
    }
655
14x
    if story.CharacterNames != nil {
656
        apiStory.CharacterNames = story.CharacterNames
657
    }
658
14x
    if story.CustomInstructions != nil {
659
        apiStory.CustomInstructions = story.CustomInstructions
660
    }
661
    // Handle enum field - only set if not nil (will be omitted from JSON due to omitempty)
662
14x
    if story.SectionLengthOverride != nil {
663
        lengthOverride := api.StorySectionLengthOverride(*story.SectionLengthOverride)
664
        apiStory.SectionLengthOverride = &lengthOverride
665
    }
666

667
14x
    if !story.CreatedAt.IsZero() {
668
14x
        apiStory.CreatedAt = timePtr(story.CreatedAt)
669
14x
    }
670
14x
    if !story.UpdatedAt.IsZero() {
671
14x
        apiStory.UpdatedAt = timePtr(story.UpdatedAt)
672
14x
    }
673
14x
    if story.LastSectionGeneratedAt != nil {
674
        apiStory.LastSectionGeneratedAt = timePtr(*story.LastSectionGeneratedAt)
675
    }
676

677
14x
    return apiStory
678
}
679

680
// Convert models.StorySection to api.StorySection
681
6x
func convertStorySectionToAPI(section *models.StorySection) api.StorySection {
682
6x
    apiSection := api.StorySection{
683
6x
        Id:            int64FromUint(section.ID),
684
6x
        StoryId:       int64FromUint(section.StoryID),
685
6x
        SectionNumber: intPtr(section.SectionNumber),
686
6x
        Content:       stringPtr(section.Content),
687
6x
        LanguageLevel: stringPtr(section.LanguageLevel),
688
6x
        WordCount:     intPtr(section.WordCount),
689
6x
    }
690
6x

691
6x
    if !section.GeneratedAt.IsZero() {
692
6x
        apiSection.GeneratedAt = timePtr(section.GeneratedAt)
693
6x
    }
694

695
    // Convert time.Time to openapi_types.Date for generation_date
696
6x
    if !section.GenerationDate.IsZero() {
697
6x
        generationDate := openapi_types.Date{Time: section.GenerationDate}
698
6x
        apiSection.GenerationDate = &generationDate
699
6x
    }
700

701
6x
    return apiSection
702
}
703

704
// Convert models.StoryWithSections to api.StoryWithSections
705
6x
func convertStoryWithSectionsToAPI(story *models.StoryWithSections) api.StoryWithSections {
706
6x
    apiStory := api.StoryWithSections{
707
6x
        Id:                   int64FromUint(story.ID),
708
6x
        UserId:               int64FromUint(story.UserID),
709
6x
        Title:                stringPtr(story.Title),
710
6x
        Language:             stringPtr(story.Language),
711
6x
        Status:               (*api.StoryWithSectionsStatus)(stringPtr(string(story.Status))),
712
6x
        AutoGenerationPaused: boolPtr(story.AutoGenerationPaused),
713
6x
    }
714
6x

715
6x
    if story.Subject != nil {
716
3x
        apiStory.Subject = story.Subject
717
3x
    }
718
6x
    if story.AuthorStyle != nil {
719
2x
        apiStory.AuthorStyle = story.AuthorStyle
720
2x
    }
721
6x
    if story.TimePeriod != nil {
722
2x
        apiStory.TimePeriod = story.TimePeriod
723
2x
    }
724
6x
    if story.Genre != nil {
725
2x
        apiStory.Genre = story.Genre
726
2x
    }
727
6x
    if story.Tone != nil {
728
2x
        apiStory.Tone = story.Tone
729
2x
    }
730
6x
    if story.CharacterNames != nil {
731
2x
        apiStory.CharacterNames = story.CharacterNames
732
2x
    }
733
6x
    if story.CustomInstructions != nil {
734
2x
        apiStory.CustomInstructions = story.CustomInstructions
735
2x
    }
736
    // Handle enum field - only set if not nil (will be omitted from JSON due to omitempty)
737
6x
    if story.SectionLengthOverride != nil {
738
2x
        lengthOverride := api.StoryWithSectionsSectionLengthOverride(*story.SectionLengthOverride)
739
2x
        apiStory.SectionLengthOverride = &lengthOverride
740
2x
    }
741

742
6x
    if !story.CreatedAt.IsZero() {
743
6x
        apiStory.CreatedAt = timePtr(story.CreatedAt)
744
6x
    }
745
6x
    if !story.UpdatedAt.IsZero() {
746
6x
        apiStory.UpdatedAt = timePtr(story.UpdatedAt)
747
6x
    }
748
6x
    if story.LastSectionGeneratedAt != nil {
749
        apiStory.LastSectionGeneratedAt = timePtr(*story.LastSectionGeneratedAt)
750
    }
751

752
    // Convert sections using the section conversion function
753
6x
    if len(story.Sections) > 0 {
754
4x
        apiSections := make([]api.StorySection, len(story.Sections))
755
4x
        for i, section := range story.Sections {
756
6x
            apiSections[i] = convertStorySectionToAPI(&section)
757
6x
        }
758
4x
        apiStory.Sections = &apiSections
759
    }
760

761
6x
    return apiStory
762
}
763

764
// Convert models.StorySectionWithQuestions to api.StorySectionWithQuestions
765
1x
func convertStorySectionWithQuestionsToAPI(sectionWithQuestions *models.StorySectionWithQuestions) api.StorySectionWithQuestions {
766
1x
    apiSectionWithQuestions := api.StorySectionWithQuestions{
767
1x
        Id:            int64FromUint(sectionWithQuestions.ID),
768
1x
        StoryId:       int64FromUint(sectionWithQuestions.StoryID),
769
1x
        SectionNumber: intPtr(sectionWithQuestions.SectionNumber),
770
1x
        Content:       stringPtr(sectionWithQuestions.Content),
771
1x
        LanguageLevel: stringPtr(sectionWithQuestions.LanguageLevel),
772
1x
        WordCount:     intPtr(sectionWithQuestions.WordCount),
773
1x
    }
774
1x

775
1x
    if !sectionWithQuestions.GeneratedAt.IsZero() {
776
1x
        apiSectionWithQuestions.GeneratedAt = timePtr(sectionWithQuestions.GeneratedAt)
777
1x
    }
778

779
    // Convert time.Time to openapi_types.Date for generation_date
780
1x
    if !sectionWithQuestions.GenerationDate.IsZero() {
781
1x
        generationDate := openapi_types.Date{Time: sectionWithQuestions.GenerationDate}
782
1x
        apiSectionWithQuestions.GenerationDate = &generationDate
783
1x
    }
784

785
    // Convert questions
786
1x
    if len(sectionWithQuestions.Questions) > 0 {
787
1x
        apiQuestions := make([]api.StorySectionQuestion, len(sectionWithQuestions.Questions))
788
1x
        for i, question := range sectionWithQuestions.Questions {
789
1x
            apiQuestions[i] = api.StorySectionQuestion{
790
1x
                Id:                 int64FromUint(question.ID),
791
1x
                SectionId:          int64FromUint(question.SectionID),
792
1x
                QuestionText:       stringPtr(question.QuestionText),
793
1x
                Options:            &question.Options,
794
1x
                CorrectAnswerIndex: intPtr(question.CorrectAnswerIndex),
795
1x
                CreatedAt:          timePtr(question.CreatedAt),
796
1x
            }
797
1x
            if question.Explanation != nil {
798
1x
                apiQuestions[i].Explanation = question.Explanation
799
1x
            }
800
        }
801
1x
        apiSectionWithQuestions.Questions = &apiQuestions
802
    }
803

804
1x
    return apiSectionWithQuestions
805
}
806


			
quizapp internal handlers worker_admin_handler.go
58.1%
Statements
172/296
1
package handlers
2

3
import (
4
    "context"
5
    "net/http"
6
    "strconv"
7
    "strings"
8
    "time"
9

10
    "quizapp/internal/api"
11
    "quizapp/internal/config"
12
    "quizapp/internal/observability"
13
    "quizapp/internal/services"
14
    contextutils "quizapp/internal/utils"
15

16
    "github.com/gin-gonic/gin"
17
    "go.opentelemetry.io/otel/attribute"
18
    "go.opentelemetry.io/otel/codes"
19
    "go.opentelemetry.io/otel/trace"
20
)
21

22
// DailyQuestionHandler handles daily question-related HTTP requests
23
type DailyQuestionHandler struct {
24
    userService          services.UserServiceInterface
25
    dailyQuestionService services.DailyQuestionServiceInterface
26
    cfg                  *config.Config
27
    logger               *observability.Logger
28
}
29

30
// NewDailyQuestionHandler creates a new DailyQuestionHandler
31
func NewDailyQuestionHandler(
32
    userService services.UserServiceInterface,
33
    dailyQuestionService services.DailyQuestionServiceInterface,
34
    cfg *config.Config,
35
    logger *observability.Logger,
36
18x
) *DailyQuestionHandler {
37
18x
    return &DailyQuestionHandler{
38
18x
        userService:          userService,
39
18x
        dailyQuestionService: dailyQuestionService,
40
18x
        cfg:                  cfg,
41
18x
        logger:               logger,
42
18x
    }
43
18x
}
44

45
// ParseDateInUserTimezone parses a date string in the user's timezone
46
42x
func (h *DailyQuestionHandler) ParseDateInUserTimezone(ctx context.Context, userID int, dateStr string) (time.Time, string, error) {
47
42x
    // Delegate to shared util with injected user lookup
48
42x
    return contextutils.ParseDateInUserTimezone(ctx, userID, dateStr, h.userService.GetUserByID)
49
42x
}
50

51
// GetDailyQuestions handles GET /v1/daily/questions/{date}
52
15x
func (h *DailyQuestionHandler) GetDailyQuestions(c *gin.Context) {
53
15x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_daily_questions")
54
15x
    defer observability.FinishSpan(span, nil)
55
15x

56
15x
    userID, exists := GetUserIDFromSession(c)
57
15x
    if !exists {
58
        HandleAppError(c, contextutils.ErrUnauthorized)
59
        return
60
    }
61

62
    // Parse date parameter
63
15x
    dateStr := c.Param("date")
64
15x
    if dateStr == "" {
65
        HandleAppError(c, contextutils.ErrMissingRequired)
66
        return
67
    }
68

69
    // Parse date in user's timezone
70
15x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
71
15x
    if err != nil {
72
1x
        // Check if it's an invalid date format error
73
1x
        if strings.Contains(err.Error(), "invalid date format") {
74
1x
            HandleAppError(c, contextutils.ErrInvalidFormat)
75
1x
            return
76
1x
        }
77
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
78
        return
79
    }
80

81
    // Add span attributes for observability
82
14x
    span.SetAttributes(
83
14x
        observability.AttributeUserID(userID),
84
14x
        attribute.String("date", dateStr),
85
14x
        attribute.String("timezone", timezone),
86
14x
    )
87
14x

88
14x
    // Get user to check current language preferences
89
14x
    user, err := h.userService.GetUserByID(ctx, userID)
90
14x
    if err != nil {
91
        h.logger.Error(ctx, "Failed to get user for language preference check", err, map[string]interface{}{
92
            "user_id": userID,
93
        })
94
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
95
        return
96
    }
97

98
    // Check if user has valid language and level preferences
99
14x
    if !user.PreferredLanguage.Valid || !user.CurrentLevel.Valid {
100
        HandleAppError(c, contextutils.ErrMissingRequired)
101
        return
102
    }
103

104
14x
    currentLanguage := user.PreferredLanguage.String
105
14x
    currentLevel := user.CurrentLevel.String
106
14x

107
14x
    // Get daily questions for the date
108
14x
    questions, err := h.dailyQuestionService.GetDailyQuestions(ctx, userID, date)
109
14x
    if err != nil {
110
        h.logger.Error(ctx, "Failed to get daily questions", err, map[string]interface{}{
111
            "user_id":  userID,
112
            "date":     dateStr,
113
            "timezone": timezone,
114
        })
115
        HandleAppError(c, contextutils.WrapError(err, "failed to get daily questions"))
116
        return
117
    }
118

119
    // Check if existing questions match current language preferences
120
14x
    needsRegeneration := false
121
14x
    var oldLanguage, oldLevel string
122
14x

123
14x
    if len(questions) == 0 {
124
        // No questions exist, need to generate them
125
        needsRegeneration = true
126
    } else {
127
14x
        // Check if existing questions match current preferences
128
14x
        oldLanguage = questions[0].Question.Language
129
14x
        oldLevel = questions[0].Question.Level
130
14x

131
14x
        for _, assignment := range questions {
132
140x
            if assignment.Question.Language != currentLanguage || assignment.Question.Level != currentLevel {
133
                needsRegeneration = true
134
                break
135
            }
136
        }
137
    }
138

139
    // If questions don't match current preferences, regenerate them
140
14x
    if needsRegeneration {
141
        h.logger.Info(ctx, "Regenerating daily questions due to language preference change", map[string]interface{}{
142
            "user_id":      userID,
143
            "date":         dateStr,
144
            "old_language": oldLanguage,
145
            "old_level":    oldLevel,
146
            "new_language": currentLanguage,
147
            "new_level":    currentLevel,
148
        })
149

150
        // Regenerate daily questions with current preferences
151
        err = h.dailyQuestionService.RegenerateDailyQuestions(ctx, userID, date)
152
        if err != nil {
153
            // Check if this is a "no questions available" error
154
            if contextutils.IsError(err, contextutils.ErrNoQuestionsAvailable) {
155
                h.logger.Warn(ctx, "No questions available in preferred language, keeping existing questions", map[string]interface{}{
156
                    "user_id":  userID,
157
                    "date":     dateStr,
158
                    "language": currentLanguage,
159
                    "level":    currentLevel,
160
                    "error":    err.Error(),
161
                })
162
                // Continue with existing questions rather than failing completely
163
            } else {
164
                h.logger.Error(ctx, "Failed to regenerate daily questions", err, map[string]interface{}{
165
                    "user_id": userID,
166
                    "date":    dateStr,
167
                })
168
                // Continue with existing questions rather than failing completely
169
                h.logger.Warn(ctx, "Continuing with existing questions due to regeneration failure", map[string]interface{}{
170
                    "user_id": userID,
171
                    "date":    dateStr,
172
                })
173
            }
174
        } else {
175
            // Get the regenerated questions
176
            questions, err = h.dailyQuestionService.GetDailyQuestions(ctx, userID, date)
177
            if err != nil {
178
                h.logger.Error(ctx, "Failed to get regenerated daily questions", err, map[string]interface{}{
179
                    "user_id": userID,
180
                    "date":    dateStr,
181
                })
182
                HandleAppError(c, contextutils.WrapError(err, "failed to get daily questions"))
183
                return
184
            }
185
        }
186
    }
187

188
    // Convert to API types using shared converter (validates content and returns error if invalid)
189
14x
    apiQuestions, err := convertDailyAssignmentsToAPI(ctx, questions, userID, h.userService.GetUserByID)
190
14x
    if err != nil {
191
        h.logger.Error(ctx, "Failed to convert daily assignments to API due to invalid content", err, map[string]interface{}{
192
            "user_id":  userID,
193
            "date":     dateStr,
194
            "timezone": timezone,
195
        })
196
        HandleAppError(c, contextutils.WrapError(err, "daily questions contain invalid content"))
197
        return
198
    }
199

200
    // Add span attributes for successful response
201
14x
    span.SetAttributes(
202
14x
        attribute.Int("questions.count", len(apiQuestions)),
203
14x
        attribute.Int("valid_questions.count", len(apiQuestions)),
204
14x
    )
205
14x

206
14x
    c.JSON(http.StatusOK, gin.H{
207
14x
        "questions": apiQuestions,
208
14x
        "date":      dateStr,
209
14x
    })
210
}
211

212
// MarkQuestionCompleted handles POST /v1/daily/questions/{date}/complete/{questionId}
213
6x
func (h *DailyQuestionHandler) MarkQuestionCompleted(c *gin.Context) {
214
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "mark_daily_question_completed")
215
6x
    defer observability.FinishSpan(span, nil)
216
6x

217
6x
    userID, exists := GetUserIDFromSession(c)
218
6x
    if !exists {
219
        HandleAppError(c, contextutils.ErrUnauthorized)
220
        return
221
    }
222

223
    // Parse parameters
224
6x
    dateStr := c.Param("date")
225
6x
    questionIDStr := c.Param("questionId")
226
6x

227
6x
    if dateStr == "" || questionIDStr == "" {
228
        HandleAppError(c, contextutils.ErrMissingRequired)
229
        return
230
    }
231

232
    // Parse date in user's timezone
233
6x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
234
6x
    if err != nil {
235
        // Check if it's an invalid date format error
236
        if strings.Contains(err.Error(), "invalid date format") {
237
            HandleAppError(c, contextutils.ErrInvalidFormat)
238
            return
239
        }
240
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
241
        return
242
    }
243

244
6x
    questionID, err := strconv.Atoi(questionIDStr)
245
6x
    if err != nil {
246
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
247
1x
        return
248
1x
    }
249

250
    // Add span attributes for observability
251
5x
    span.SetAttributes(
252
5x
        observability.AttributeUserID(userID),
253
5x
        attribute.String("date", dateStr),
254
5x
        attribute.Int("question_id", questionID),
255
5x
        attribute.String("timezone", timezone),
256
5x
    )
257
5x

258
5x
    // Mark question as completed
259
5x
    err = h.dailyQuestionService.MarkQuestionCompleted(ctx, userID, questionID, date)
260
5x
    if err != nil {
261
        h.logger.Error(ctx, "Failed to mark daily question as completed", err, map[string]interface{}{
262
            "user_id":     userID,
263
            "question_id": questionID,
264
            "date":        dateStr,
265
            "timezone":    timezone,
266
        })
267

268
        // Check if the error indicates no assignment was found
269
        if contextutils.IsError(err, contextutils.ErrAssignmentNotFound) {
270
            HandleAppError(c, contextutils.ErrAssignmentNotFound)
271
            return
272
        }
273

274
        HandleAppError(c, contextutils.WrapError(err, "failed to mark question as completed"))
275
        return
276
    }
277

278
5x
    c.JSON(http.StatusOK, api.SuccessResponse{
279
5x
        Message: stringPtr("Question marked as completed"),
280
5x
        Success: true,
281
5x
    })
282
}
283

284
// ResetQuestionCompleted handles DELETE /v1/daily/questions/{date}/complete/{questionId}
285
3x
func (h *DailyQuestionHandler) ResetQuestionCompleted(c *gin.Context) {
286
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "reset_daily_question_completed")
287
3x
    defer observability.FinishSpan(span, nil)
288
3x

289
3x
    userID, exists := GetUserIDFromSession(c)
290
3x
    if !exists {
291
        HandleAppError(c, contextutils.ErrUnauthorized)
292
        return
293
    }
294

295
    // Parse parameters
296
3x
    dateStr := c.Param("date")
297
3x
    questionIDStr := c.Param("questionId")
298
3x

299
3x
    if dateStr == "" || questionIDStr == "" {
300
        HandleAppError(c, contextutils.ErrMissingRequired)
301
        return
302
    }
303

304
    // Parse date in user's timezone
305
3x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
306
3x
    if err != nil {
307
        // Check if it's an invalid date format error
308
        if strings.Contains(err.Error(), "invalid date format") {
309
            HandleAppError(c, contextutils.ErrInvalidFormat)
310
            return
311
        }
312
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
313
        return
314
    }
315

316
3x
    questionID, err := strconv.Atoi(questionIDStr)
317
3x
    if err != nil {
318
        HandleAppError(c, contextutils.ErrInvalidFormat)
319
        return
320
    }
321

322
    // Add span attributes for observability
323
3x
    span.SetAttributes(
324
3x
        observability.AttributeUserID(userID),
325
3x
        attribute.String("date", dateStr),
326
3x
        attribute.Int("question_id", questionID),
327
3x
        attribute.String("timezone", timezone),
328
3x
    )
329
3x

330
3x
    // Reset question completion status
331
3x
    err = h.dailyQuestionService.ResetQuestionCompleted(ctx, userID, questionID, date)
332
3x
    if err != nil {
333
        h.logger.Error(ctx, "Failed to reset daily question completion", err, map[string]interface{}{
334
            "user_id":     userID,
335
            "question_id": questionID,
336
            "date":        dateStr,
337
            "timezone":    timezone,
338
        })
339

340
        // Check if the error indicates no assignment was found
341
        if contextutils.IsError(err, contextutils.ErrAssignmentNotFound) {
342
            HandleAppError(c, contextutils.ErrAssignmentNotFound)
343
            return
344
        }
345

346
        HandleAppError(c, contextutils.WrapError(err, "failed to reset question completion"))
347
        return
348
    }
349

350
3x
    c.JSON(http.StatusOK, api.SuccessResponse{
351
3x
        Message: stringPtr("Question completion reset"),
352
3x
        Success: true,
353
3x
    })
354
}
355

356
// GetAvailableDates handles GET /v1/daily/dates
357
1x
func (h *DailyQuestionHandler) GetAvailableDates(c *gin.Context) {
358
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_daily_available_dates")
359
1x
    defer observability.FinishSpan(span, nil)
360
1x

361
1x
    userID, exists := GetUserIDFromSession(c)
362
1x
    if !exists {
363
        HandleAppError(c, contextutils.ErrUnauthorized)
364
        return
365
    }
366

367
    // Add span attributes for observability
368
1x
    span.SetAttributes(observability.AttributeUserID(userID))
369
1x

370
1x
    // Get available dates with assignments
371
1x
    dates, err := h.dailyQuestionService.GetAvailableDates(ctx, userID)
372
1x
    if err != nil {
373
        h.logger.Error(ctx, "Failed to get available dates", err, map[string]interface{}{
374
            "user_id": userID,
375
        })
376
        HandleAppError(c, contextutils.WrapError(err, "failed to get available dates"))
377
        return
378
    }
379

380
    // Exclude future dates based on the user's timezone (clients expect local calendar days only)
381
1x
    user, _ := h.userService.GetUserByID(ctx, userID)
382
1x
    tz := "UTC"
383
1x
    if user != nil && user.Timezone.Valid && user.Timezone.String != "" {
384
1x
        tz = user.Timezone.String
385
1x
    }
386
1x
    loc, err := time.LoadLocation(tz)
387
1x
    if err != nil {
388
        loc = time.UTC
389
    }
390
1x
    now := time.Now().In(loc)
391
1x
    today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
392
1x

393
1x
    // Filter out dates that are after today in the user's timezone
394
1x
    var filtered []time.Time
395
1x
    for _, d := range dates {
396
2x
        // Treat the date value as a date-only value (time component ignored)
397
2x
        if !d.After(today) {
398
2x
            filtered = append(filtered, d)
399
2x
        }
400
    }
401

402
    // Convert dates to string format for JSON response
403
1x
    dateStrings := make([]string, len(filtered))
404
1x
    for i, date := range filtered {
405
2x
        dateStrings[i] = date.Format("2006-01-02")
406
2x
    }
407

408
1x
    c.JSON(http.StatusOK, gin.H{
409
1x
        "dates": dateStrings,
410
1x
    })
411
}
412

413
// Note: Daily question assignment is now handled automatically by the worker
414
// when sending daily reminder emails. No manual assignment endpoint needed.
415

416
// GetDailyProgress handles GET /v1/daily/progress/{date}
417
4x
func (h *DailyQuestionHandler) GetDailyProgress(c *gin.Context) {
418
4x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_daily_progress")
419
4x
    defer observability.FinishSpan(span, nil)
420
4x

421
4x
    userID, exists := GetUserIDFromSession(c)
422
4x
    if !exists {
423
        HandleAppError(c, contextutils.ErrUnauthorized)
424
        return
425
    }
426

427
    // Parse date parameter
428
4x
    dateStr := c.Param("date")
429
4x
    if dateStr == "" {
430
        HandleAppError(c, contextutils.ErrMissingRequired)
431
        return
432
    }
433

434
    // Parse date in user's timezone
435
4x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
436
4x
    if err != nil {
437
        // Check if it's an invalid date format error
438
        if strings.Contains(err.Error(), "invalid date format") {
439
            HandleAppError(c, contextutils.ErrInvalidFormat)
440
            return
441
        }
442
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
443
        return
444
    }
445

446
    // Add span attributes for observability
447
4x
    span.SetAttributes(
448
4x
        observability.AttributeUserID(userID),
449
4x
        attribute.String("date", dateStr),
450
4x
        attribute.String("timezone", timezone),
451
4x
    )
452
4x

453
4x
    // Get daily progress for the date
454
4x
    progress, err := h.dailyQuestionService.GetDailyProgress(ctx, userID, date)
455
4x
    if err != nil {
456
        h.logger.Error(ctx, "Failed to get daily progress", err, map[string]interface{}{
457
            "user_id":  userID,
458
            "date":     dateStr,
459
            "timezone": timezone,
460
        })
461
        HandleAppError(c, contextutils.WrapError(err, "failed to get daily progress"))
462
        return
463
    }
464

465
    // Convert to API type using shared converter
466
4x
    apiProgress := convertDailyProgressToAPI(progress)
467
4x

468
4x
    c.JSON(http.StatusOK, apiProgress)
469
}
470

471
// SubmitDailyQuestionAnswer handles POST /v1/daily/questions/{date}/answer/{questionId}
472
9x
func (h *DailyQuestionHandler) SubmitDailyQuestionAnswer(c *gin.Context) {
473
9x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "submit_daily_question_answer")
474
9x
    defer observability.FinishSpan(span, nil)
475
9x

476
9x
    h.logger.Info(ctx, "SubmitDailyQuestionAnswer handler called", map[string]interface{}{
477
9x
        "method": c.Request.Method,
478
9x
        "path":   c.Request.URL.Path,
479
9x
        "params": c.Params,
480
9x
    })
481
9x

482
9x
    userID, exists := GetUserIDFromSession(c)
483
9x
    if !exists {
484
        HandleAppError(c, contextutils.ErrUnauthorized)
485
        return
486
    }
487

488
    // Parse parameters
489
9x
    dateStr := c.Param("date")
490
9x
    questionIDStr := c.Param("questionId")
491
9x

492
9x
    if dateStr == "" || questionIDStr == "" {
493
        HandleAppError(c, contextutils.ErrMissingRequired)
494
        return
495
    }
496

497
    // Parse date in user's timezone
498
9x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
499
9x
    if err != nil {
500
        // Check if it's an invalid date format error
501
        if strings.Contains(err.Error(), "invalid date format") {
502
            HandleAppError(c, contextutils.ErrInvalidFormat)
503
            return
504
        }
505
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
506
        return
507
    }
508

509
9x
    questionID, err := strconv.Atoi(questionIDStr)
510
9x
    if err != nil {
511
        HandleAppError(c, contextutils.ErrInvalidFormat)
512
        return
513
    }
514

515
    // Parse request body
516
9x
    var requestBody api.PostV1DailyQuestionsDateAnswerQuestionIdJSONBody
517
9x

518
9x
    h.logger.Info(ctx, "Parsing request body", map[string]interface{}{
519
9x
        "user_id":     userID,
520
9x
        "question_id": questionID,
521
9x
        "date":        dateStr,
522
9x
        "timezone":    timezone,
523
9x
    })
524
9x

525
9x
    if err := c.ShouldBindJSON(&requestBody); err != nil {
526
        h.logger.Error(ctx, "Failed to parse request body", err, map[string]interface{}{
527
            "user_id":     userID,
528
            "question_id": questionID,
529
            "date":        dateStr,
530
            "timezone":    timezone,
531
            "error":       err.Error(),
532
        })
533
        HandleAppError(c, contextutils.NewAppErrorWithCause(
534
            contextutils.ErrorCodeInvalidInput,
535
            contextutils.SeverityWarn,
536
            "Invalid request body",
537
            "",
538
            err,
539
        ))
540
        return
541
    }
542

543
9x
    h.logger.Info(ctx, "Request body parsed successfully",
544
9x
        map[string]interface{}{
545
9x
            "user_id":           userID,
546
9x
            "question_id":       questionID,
547
9x
            "date":              dateStr,
548
9x
            "timezone":          timezone,
549
9x
            "user_answer_index": requestBody.UserAnswerIndex,
550
9x
        })
551
9x

552
9x
    // Validate user answer index
553
9x
    if requestBody.UserAnswerIndex < 0 {
554
        h.logger.Warn(ctx, "Invalid user answer index in SubmitDailyQuestionAnswer", map[string]interface{}{"user_answer_index": requestBody.UserAnswerIndex})
555
        HandleAppError(c, contextutils.ErrInvalidAnswerIndex)
556
        return
557
    }
558

559
    // Add span attributes for observability
560
9x
    span.SetAttributes(
561
9x
        observability.AttributeUserID(userID),
562
9x
        attribute.String("date", dateStr),
563
9x
        attribute.Int("question_id", questionID),
564
9x
        attribute.String("timezone", timezone),
565
9x
        attribute.Int("user_answer_index", requestBody.UserAnswerIndex),
566
9x
    )
567
9x

568
9x
    // Submit the answer
569
9x
    response, err := h.dailyQuestionService.SubmitDailyQuestionAnswer(
570
9x
        ctx,
571
9x
        userID,
572
9x
        questionID,
573
9x
        date,
574
9x
        requestBody.UserAnswerIndex,
575
9x
    )
576
9x
    if err != nil {
577
        h.logger.Error(ctx, "Failed to submit daily question answer", err, map[string]interface{}{
578
            "user_id":           userID,
579
            "question_id":       questionID,
580
            "date":              dateStr,
581
            "timezone":          timezone,
582
            "user_answer_index": requestBody.UserAnswerIndex,
583
        })
584

585
        // Check for specific error types
586
        if contextutils.IsError(err, contextutils.ErrQuestionAlreadyAnswered) {
587
            HandleAppError(c, contextutils.ErrQuestionAlreadyAnswered)
588
            return
589
        }
590
        if contextutils.IsError(err, contextutils.ErrAssignmentNotFound) {
591
            HandleAppError(c, contextutils.ErrAssignmentNotFound)
592
            return
593
        }
594
        if contextutils.IsError(err, contextutils.ErrInvalidAnswerIndex) {
595
            HandleAppError(c, contextutils.ErrInvalidAnswerIndex)
596
            return
597
        }
598

599
        HandleAppError(c, contextutils.WrapError(err, "failed to submit answer"))
600
        return
601
    }
602

603
    // Add completion status to response
604
9x
    responseWithCompletion := gin.H{
605
9x
        "is_completed": true,
606
9x
    }
607
9x

608
9x
    if response.UserAnswerIndex != nil {
609
9x
        responseWithCompletion["user_answer_index"] = *response.UserAnswerIndex
610
9x
    }
611
9x
    if response.UserAnswer != nil {
612
9x
        responseWithCompletion["user_answer"] = *response.UserAnswer
613
9x
    }
614
9x
    if response.IsCorrect != nil {
615
9x
        responseWithCompletion["is_correct"] = *response.IsCorrect
616
9x
    }
617
9x
    if response.CorrectAnswerIndex != nil {
618
9x
        responseWithCompletion["correct_answer_index"] = *response.CorrectAnswerIndex
619
9x
    }
620
9x
    if response.Explanation != nil {
621
8x
        responseWithCompletion["explanation"] = *response.Explanation
622
8x
    }
623

624
9x
    c.JSON(http.StatusOK, responseWithCompletion)
625
}
626

627
// GetQuestionHistory handles GET /v1/daily/questions/{questionId}/history
628
7x
func (h *DailyQuestionHandler) GetQuestionHistory(c *gin.Context) {
629
7x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_question_history")
630
7x
    defer observability.FinishSpan(span, nil)
631
7x

632
7x
    userID, exists := GetUserIDFromSession(c)
633
7x
    if !exists {
634
        HandleAppError(c, contextutils.ErrUnauthorized)
635
        return
636
    }
637

638
    // Parse question ID parameter
639
7x
    questionIDStr := c.Param("questionId")
640
7x
    if questionIDStr == "" {
641
        HandleAppError(c, contextutils.ErrMissingRequired)
642
        return
643
    }
644

645
7x
    questionID, err := strconv.Atoi(questionIDStr)
646
7x
    if err != nil {
647
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
648
1x
        return
649
1x
    }
650

651
    // Add span attributes for observability
652
6x
    span.SetAttributes(
653
6x
        observability.AttributeUserID(userID),
654
6x
        attribute.Int("question_id", questionID),
655
6x
    )
656
6x

657
6x
    // Get question history for the last 14 days
658
6x
    history, err := h.dailyQuestionService.GetQuestionHistory(ctx, userID, questionID, 14)
659
6x
    if err != nil {
660
        h.logger.Error(ctx, "Failed to get question history", err, map[string]interface{}{
661
            "user_id":     userID,
662
            "question_id": questionID,
663
        })
664
        HandleAppError(c, contextutils.WrapError(err, "failed to get question history"))
665
        return
666
    }
667

668
    // Determine user's timezone/location once, then filter out any future-dated assignments
669
6x
    user, _ := h.userService.GetUserByID(ctx, userID)
670
6x
    tz := "UTC"
671
6x
    if user != nil && user.Timezone.Valid && user.Timezone.String != "" {
672
4x
        tz = user.Timezone.String
673
4x
    }
674
6x
    loc, locErr := time.LoadLocation(tz)
675
6x
    if locErr != nil {
676
        loc = time.UTC
677
    }
678
6x
    now := time.Now().In(loc)
679
6x
    today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
680
6x

681
6x
    // Format times in user's timezone using helper, skipping future dates
682
6x
    resp := make([]map[string]interface{}, 0, len(history))
683
6x
    for _, he := range history {
684
9x
        // Skip future assignments in user's local date
685
9x
        ad := he.AssignmentDate.In(loc)
686
9x
        adDate := time.Date(ad.Year(), ad.Month(), ad.Day(), 0, 0, 0, 0, loc)
687
9x
        if adDate.After(today) {
688
1x
            continue
689
        }
690

691
        // Return assignment_date as date-only string (YYYY-MM-DD) using the stored UTC
692
        // date to avoid timezone ambiguity for clients.
693
8x
        assignDateStr := he.AssignmentDate.UTC().Format("2006-01-02")
694
8x
        span.SetAttributes(attribute.String("assignment_date.formatted_with", "date_only"))
695
8x

696
8x
        entry := map[string]interface{}{
697
8x
            "assignment_date": assignDateStr,
698
8x
            "is_completed":    he.IsCompleted,
699
8x
            "is_correct":      nil,
700
8x
            "submitted_at":    nil,
701
8x
        }
702
8x
        if he.IsCorrect != nil {
703
1x
            entry["is_correct"] = *he.IsCorrect
704
1x
        }
705
8x
        if he.SubmittedAt != nil {
706
4x
            submittedStr, _, submittedErr := contextutils.FormatTimeInUserTimezone(ctx, userID, *he.SubmittedAt, time.RFC3339, h.userService.GetUserByID)
707
4x
            if submittedErr != nil || submittedStr == "" {
708
                h.logger.Error(ctx, "Failed to format submitted_at in user's timezone", submittedErr, map[string]interface{}{
709
                    "user_id":         userID,
710
                    "question_id":     questionID,
711
                    "submitted_at_db": he.SubmittedAt,
712
                })
713
                span.RecordError(submittedErr, trace.WithStackTrace(true))
714
                span.SetStatus(codes.Error, "failed to format submitted_at")
715
                HandleAppError(c, contextutils.WrapError(submittedErr, "failed to format submitted_at"))
716
                return
717
            }
718
4x
            span.SetAttributes(attribute.String("submitted_at.formatted_with", "user_timezone"))
719
4x
            entry["submitted_at"] = submittedStr
720
        }
721
8x
        resp = append(resp, entry)
722
    }
723

724
6x
    c.JSON(http.StatusOK, gin.H{"history": resp})
725
}
726


			
quizapp internal handlers worker_admin_handler.go
64.4%
Statements
29/45
1
package handlers
2

3
import (
4
    "fmt"
5
    "net/http"
6

7
    contextutils "quizapp/internal/utils"
8

9
    "github.com/gin-gonic/gin"
10
)
11

12
// StandardizeHTTPError creates consistent HTTP error responses with structured error information
13
19x
func StandardizeHTTPError(c *gin.Context, statusCode int, message, details string) {
14
19x
    // Map HTTP status code to appropriate error code
15
19x
    var errorCode contextutils.ErrorCode
16
19x
    var severity contextutils.SeverityLevel
17
19x

18
19x
    switch statusCode {
19
9x
    case http.StatusBadRequest:
20
9x
        errorCode = contextutils.ErrorCodeInvalidInput
21
9x
        severity = contextutils.SeverityWarn
22
    case http.StatusUnauthorized:
23
        errorCode = contextutils.ErrorCodeUnauthorized
24
        severity = contextutils.SeverityWarn
25
    case http.StatusForbidden:
26
        errorCode = contextutils.ErrorCodeForbidden
27
        severity = contextutils.SeverityWarn
28
7x
    case http.StatusNotFound:
29
7x
        errorCode = contextutils.ErrorCodeRecordNotFound
30
7x
        severity = contextutils.SeverityInfo
31
    case http.StatusConflict:
32
        errorCode = contextutils.ErrorCodeRecordExists
33
        severity = contextutils.SeverityInfo
34
    case http.StatusServiceUnavailable:
35
        errorCode = contextutils.ErrorCodeServiceUnavailable
36
        severity = contextutils.SeverityError
37
3x
    default:
38
3x
        errorCode = contextutils.ErrorCodeInternalError
39
3x
        severity = contextutils.SeverityError
40
    }
41

42
    // Create an AppError with appropriate code
43
19x
    appErr := contextutils.NewAppError(
44
19x
        errorCode,
45
19x
        severity,
46
19x
        message,
47
19x
        details,
48
19x
    )
49
19x

50
19x
    // Send response with the original status code
51
19x
    c.JSON(statusCode, appErr.ToJSON())
52
}
53

54
// StandardizeAppError sends a structured error response using AppError
55
114x
func StandardizeAppError(c *gin.Context, err *contextutils.AppError) {
56
114x
    // Map error codes to HTTP status codes
57
114x
    statusCode := mapErrorCodeToHTTPStatus(err.Code)
58
114x

59
114x
    // Convert error to JSON structure
60
114x
    errorJSON := err.ToJSON()
61
114x

62
114x
    // Add retryable information based on error type
63
114x
    errorJSON["retryable"] = contextutils.IsRetryable(err)
64
114x

65
114x
    c.JSON(statusCode, errorJSON)
66
114x
}
67

68
// HandleValidationError handles input validation errors consistently
69
9x
func HandleValidationError(c *gin.Context, field string, value interface{}, reason string) {
70
9x
    appErr := contextutils.NewAppError(
71
9x
        contextutils.ErrorCodeInvalidInput,
72
9x
        contextutils.SeverityWarn,
73
9x
        fmt.Sprintf("Invalid %s", field),
74
9x
        fmt.Sprintf("Value '%v' is invalid: %s", value, reason),
75
9x
    )
76
9x

77
9x
    StandardizeAppError(c, appErr)
78
9x
}
79

80
// HandleAppError handles any AppError and sends appropriate HTTP response
81
105x
func HandleAppError(c *gin.Context, err error) {
82
105x
    if appErr, ok := err.(*contextutils.AppError); ok {
83
105x
        // Special-case: no questions available should return 202 with GeneratingResponse body
84
105x
        if appErr.Code == contextutils.ErrorCodeNoQuestionsAvailable {
85
            // 202 Accepted with generating payload (matches swagger GeneratingResponse)
86
            c.JSON(http.StatusAccepted, gin.H{
87
                "status":  "generating",
88
                "message": "No questions available. Please try again shortly.",
89
            })
90
            return
91
        }
92
105x
        StandardizeAppError(c, appErr)
93
    } else {
94
        // Fallback for non-AppError types
95
        StandardizeHTTPError(c, http.StatusInternalServerError, "Internal server error", err.Error())
96
    }
97
}
98

99
// mapErrorCodeToHTTPStatus maps AppError codes to appropriate HTTP status codes
100
114x
func mapErrorCodeToHTTPStatus(code contextutils.ErrorCode) int {
101
114x
    switch code {
102
    case contextutils.ErrorCodeNoQuestionsAvailable:
103
        return http.StatusAccepted
104
    // 4xx Client Errors
105
    case contextutils.ErrorCodeInvalidInput, contextutils.ErrorCodeMissingRequired,
106
        contextutils.ErrorCodeInvalidFormat, contextutils.ErrorCodeValidationFailed,
107
69x
        contextutils.ErrorCodeOAuthStateMismatch:
108
69x
        return http.StatusBadRequest
109

110
1x
    case contextutils.ErrorCodeUnauthorized:
111
1x
        return http.StatusUnauthorized
112

113
2x
    case contextutils.ErrorCodeForbidden:
114
2x
        return http.StatusForbidden
115

116
    case contextutils.ErrorCodeRecordNotFound, contextutils.ErrorCodeQuestionNotFound,
117
13x
        contextutils.ErrorCodeAssignmentNotFound:
118
13x
        return http.StatusNotFound
119

120
6x
    case contextutils.ErrorCodeRecordExists, contextutils.ErrorCodeGenerationLimitReached:
121
6x
        return http.StatusConflict
122

123
10x
    case contextutils.ErrorCodeSessionExpired, contextutils.ErrorCodeInvalidCredentials:
124
10x
        return http.StatusUnauthorized
125

126
    case contextutils.ErrorCodeRateLimit:
127
        return http.StatusTooManyRequests
128

129
    // 5xx Server Errors
130
9x
    case contextutils.ErrorCodeInternalError:
131
9x
        return http.StatusInternalServerError
132

133
    case contextutils.ErrorCodeServiceUnavailable, contextutils.ErrorCodeDatabaseConnection,
134
1x
        contextutils.ErrorCodeAIProviderUnavailable:
135
1x
        return http.StatusServiceUnavailable
136

137
    case contextutils.ErrorCodeTimeout:
138
        return http.StatusRequestTimeout
139

140
    case contextutils.ErrorCodeDatabaseQuery, contextutils.ErrorCodeDatabaseTransaction,
141
        contextutils.ErrorCodeForeignKeyViolation, contextutils.ErrorCodeTimestampMissingTimezone,
142
        contextutils.ErrorCodeAIRequestFailed, contextutils.ErrorCodeAIResponseInvalid,
143
        contextutils.ErrorCodeAIConfigInvalid, contextutils.ErrorCodeOAuthProviderError:
144
        return http.StatusInternalServerError
145

146
    // Default to internal server error for unknown codes
147
    default:
148
        return http.StatusInternalServerError
149
    }
150
}
151


			
quizapp internal handlers worker_admin_handler.go
55.0%
Statements
127/231
1
package handlers
2

3
import (
4
    "database/sql"
5
    "fmt"
6
    "net/http"
7
    "strconv"
8
    "strings"
9
    "time"
10

11
    "github.com/gin-gonic/gin"
12

13
    "quizapp/internal/config"
14
    "quizapp/internal/models"
15
    "quizapp/internal/observability"
16
    serviceinterfaces "quizapp/internal/serviceinterfaces"
17
    "quizapp/internal/services"
18
    contextutils "quizapp/internal/utils"
19
)
20

21
// FeedbackResponse represents the JSON response for feedback listing
22
type FeedbackResponse struct {
23
    ID               int                    `json:"id"`
24
    UserID           int                    `json:"user_id"`
25
    FeedbackText     string                 `json:"feedback_text"`
26
    FeedbackType     string                 `json:"feedback_type"`
27
    ContextData      map[string]interface{} `json:"context_data"`
28
    ScreenshotData   *string                `json:"screenshot_data"`
29
    ScreenshotURL    *string                `json:"screenshot_url"`
30
    Status           string                 `json:"status"`
31
    AdminNotes       *string                `json:"admin_notes"`
32
    AssignedToUserID *int32                 `json:"assigned_to_user_id"`
33
    ResolvedAt       *string                `json:"resolved_at"`
34
    ResolvedByUserID *int32                 `json:"resolved_by_user_id"`
35
    CreatedAt        string                 `json:"created_at"`
36
    UpdatedAt        string                 `json:"updated_at"`
37
}
38

39
// ensureContextDataNotNull returns an empty map if the input is nil
40
21x
func ensureContextDataNotNull(data map[string]interface{}) map[string]interface{} {
41
21x
    if data == nil {
42
19x
        return map[string]interface{}{}
43
19x
    }
44
2x
    return data
45
}
46

47
// convertFeedbackToResponse converts FeedbackReport to FeedbackResponse
48
21x
func convertFeedbackToResponse(fr models.FeedbackReport) FeedbackResponse {
49
21x
    response := FeedbackResponse{
50
21x
        ID:           fr.ID,
51
21x
        UserID:       fr.UserID,
52
21x
        FeedbackText: fr.FeedbackText,
53
21x
        FeedbackType: fr.FeedbackType,
54
21x
        ContextData:  ensureContextDataNotNull(fr.ContextData),
55
21x
        Status:       fr.Status,
56
21x
        CreatedAt:    fr.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
57
21x
        UpdatedAt:    fr.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
58
21x
    }
59
21x

60
21x
    if fr.ScreenshotData.Valid {
61
1x
        response.ScreenshotData = &fr.ScreenshotData.String
62
1x
    }
63
21x
    if fr.ScreenshotURL.Valid {
64
        response.ScreenshotURL = &fr.ScreenshotURL.String
65
    }
66
21x
    if fr.AdminNotes.Valid {
67
1x
        response.AdminNotes = &fr.AdminNotes.String
68
1x
    }
69
21x
    if fr.AssignedToUserID.Valid {
70
        response.AssignedToUserID = &fr.AssignedToUserID.Int32
71
    }
72
21x
    if fr.ResolvedAt.Valid {
73
        at := fr.ResolvedAt.Time.Format("2006-01-02T15:04:05Z07:00")
74
        response.ResolvedAt = &at
75
    }
76
21x
    if fr.ResolvedByUserID.Valid {
77
        response.ResolvedByUserID = &fr.ResolvedByUserID.Int32
78
    }
79

80
21x
    return response
81
}
82

83
// FeedbackHandler handles feedback report endpoints.
84
type FeedbackHandler struct {
85
    feedbackService serviceinterfaces.FeedbackServiceInterface
86
    linearService   *services.LinearService
87
    userService     services.UserServiceInterface
88
    config          *config.Config
89
    logger          *observability.Logger
90
}
91

92
// NewFeedbackHandler creates a FeedbackHandler.
93
17x
func NewFeedbackHandler(fs serviceinterfaces.FeedbackServiceInterface, linearService *services.LinearService, userService services.UserServiceInterface, cfg *config.Config, logger *observability.Logger) *FeedbackHandler {
94
17x
    return &FeedbackHandler{
95
17x
        feedbackService: fs,
96
17x
        linearService:   linearService,
97
17x
        userService:     userService,
98
17x
        config:          cfg,
99
17x
        logger:          logger,
100
17x
    }
101
17x
}
102

103
// FeedbackSubmissionRequest represents a POST request.
104
type FeedbackSubmissionRequest struct {
105
    FeedbackText   string                 `json:"feedback_text" binding:"required"`
106
    FeedbackType   string                 `json:"feedback_type"`
107
    ContextData    map[string]interface{} `json:"context_data"`
108
    ScreenshotData string                 `json:"screenshot_data"`
109
}
110

111
// SubmitFeedback handles POST /v1/feedback.
112
14x
func (h *FeedbackHandler) SubmitFeedback(c *gin.Context) {
113
14x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "submit_feedback")
114
14x
    defer observability.FinishSpan(span, nil)
115
14x

116
14x
    // Get user ID from Gin context (set by auth middleware)
117
14x
    userID, exists := GetUserIDFromSession(c)
118
14x
    if !exists {
119
        HandleAppError(c, contextutils.ErrUnauthorized)
120
        return
121
    }
122

123
    // Add user ID to Go context for service layers
124
14x
    ctx = contextutils.WithUserID(ctx, userID)
125
14x

126
14x
    var req FeedbackSubmissionRequest
127
14x
    if err := c.ShouldBindJSON(&req); err != nil {
128
2x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
129
2x
            contextutils.ErrorCodeInvalidInput,
130
2x
            contextutils.SeverityWarn,
131
2x
            "Invalid request body",
132
2x
            "",
133
2x
            err,
134
2x
        ))
135
2x
        return
136
2x
    }
137

138
12x
    feedbackType := req.FeedbackType
139
12x
    if feedbackType == "" {
140
1x
        feedbackType = "general"
141
1x
    }
142

143
12x
    var screenshotData sql.NullString
144
12x
    if req.ScreenshotData != "" {
145
1x
        screenshotData = sql.NullString{String: req.ScreenshotData, Valid: true}
146
1x
    }
147

148
12x
    fr := &models.FeedbackReport{
149
12x
        UserID:         userID,
150
12x
        FeedbackText:   req.FeedbackText,
151
12x
        FeedbackType:   feedbackType,
152
12x
        ContextData:    req.ContextData,
153
12x
        ScreenshotData: screenshotData,
154
12x
        Status:         "new",
155
12x
    }
156
12x

157
12x
    created, err := h.feedbackService.CreateFeedback(ctx, fr)
158
12x
    if err != nil {
159
        h.logger.Error(ctx, "create feedback failed", err, nil)
160
        HandleAppError(c, err)
161
        return
162
    }
163

164
12x
    c.JSON(http.StatusCreated, convertFeedbackToResponse(*created))
165
}
166

167
// GetFeedback handles GET /v1/admin/backend/feedback/:id.
168
2x
func (h *FeedbackHandler) GetFeedback(c *gin.Context) {
169
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_feedback")
170
2x
    defer observability.FinishSpan(span, nil)
171
2x

172
2x
    id, err := strconv.Atoi(c.Param("id"))
173
2x
    if err != nil {
174
        HandleAppError(c, contextutils.ErrInvalidFormat)
175
        return
176
    }
177

178
2x
    feedback, err := h.feedbackService.GetFeedbackByID(ctx, id)
179
2x
    if err != nil {
180
1x
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
181
1x
            HandleAppError(c, contextutils.ErrRecordNotFound)
182
1x
            return
183
1x
        }
184
        h.logger.Error(ctx, "get feedback failed", err, nil)
185
        HandleAppError(c, err)
186
        return
187
    }
188

189
1x
    c.JSON(http.StatusOK, convertFeedbackToResponse(*feedback))
190
}
191

192
// ListFeedback handles GET /v1/admin/feedback.
193
2x
func (h *FeedbackHandler) ListFeedback(c *gin.Context) {
194
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "list_feedback")
195
2x
    defer observability.FinishSpan(span, nil)
196
2x

197
2x
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
198
2x
    pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
199
2x
    status := c.Query("status")
200
2x
    feedbackType := c.Query("feedback_type")
201
2x
    userIDStr := c.Query("user_id")
202
2x

203
2x
    var userID *int
204
2x
    if userIDStr != "" {
205
        id, _ := strconv.Atoi(userIDStr)
206
        userID = &id
207
    }
208

209
2x
    list, total, err := h.feedbackService.GetFeedbackPaginated(ctx, page, pageSize, status, feedbackType, userID)
210
2x
    if err != nil {
211
        h.logger.Error(ctx, "list feedback failed", err, nil)
212
        HandleAppError(c, err)
213
        return
214
    }
215

216
    // Convert each feedback item to response format
217
2x
    items := make([]FeedbackResponse, len(list))
218
2x
    for i, item := range list {
219
7x
        items[i] = convertFeedbackToResponse(item)
220
7x
    }
221

222
2x
    c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": pageSize})
223
}
224

225
// UpdateFeedback handles PATCH /v1/admin/feedback/:id.
226
1x
func (h *FeedbackHandler) UpdateFeedback(c *gin.Context) {
227
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "update_feedback")
228
1x
    defer observability.FinishSpan(span, nil)
229
1x

230
1x
    id, err := strconv.Atoi(c.Param("id"))
231
1x
    if err != nil {
232
        HandleAppError(c, contextutils.ErrorWithContextf("invalid feedback ID"))
233
        return
234
    }
235

236
1x
    var updates map[string]interface{}
237
1x
    if err := c.ShouldBindJSON(&updates); err != nil {
238
        HandleAppError(c, contextutils.WrapError(err, "invalid request body"))
239
        return
240
    }
241

242
1x
    updated, err := h.feedbackService.UpdateFeedback(ctx, id, updates)
243
1x
    if err != nil {
244
        h.logger.Error(ctx, "update feedback failed", err, nil)
245
        HandleAppError(c, err)
246
        return
247
    }
248

249
1x
    c.JSON(http.StatusOK, convertFeedbackToResponse(*updated))
250
}
251

252
// DeleteFeedback handles DELETE /v1/admin/backend/feedback/:id.
253
func (h *FeedbackHandler) DeleteFeedback(c *gin.Context) {
254
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "delete_feedback")
255
    defer observability.FinishSpan(span, nil)
256

257
    id, err := strconv.Atoi(c.Param("id"))
258
    if err != nil {
259
        HandleAppError(c, contextutils.ErrorWithContextf("invalid feedback ID"))
260
        return
261
    }
262

263
    err = h.feedbackService.DeleteFeedback(ctx, id)
264
    if err != nil {
265
        h.logger.Error(ctx, "delete feedback failed", err, nil)
266
        HandleAppError(c, err)
267
        return
268
    }
269

270
    c.Status(http.StatusNoContent)
271
}
272

273
// DeleteFeedbackByStatus handles DELETE /v1/admin/backend/feedback?status=resolved.
274
2x
func (h *FeedbackHandler) DeleteFeedbackByStatus(c *gin.Context) {
275
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "delete_feedback_by_status")
276
2x
    defer observability.FinishSpan(span, nil)
277
2x

278
2x
    status := c.Query("status")
279
2x
    if status == "" {
280
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
281
1x
        return
282
1x
    }
283

284
1x
    count, err := h.feedbackService.DeleteFeedbackByStatus(ctx, status)
285
1x
    if err != nil {
286
        h.logger.Error(ctx, "delete feedback by status failed", err, nil)
287
        HandleAppError(c, err)
288
        return
289
    }
290

291
1x
    c.JSON(http.StatusOK, gin.H{"deleted_count": count})
292
}
293

294
// DeleteAllFeedback handles DELETE /v1/admin/backend/feedback?all=true.
295
func (h *FeedbackHandler) DeleteAllFeedback(c *gin.Context) {
296
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "delete_all_feedback")
297
    defer observability.FinishSpan(span, nil)
298

299
    count, err := h.feedbackService.DeleteAllFeedback(ctx)
300
    if err != nil {
301
        h.logger.Error(ctx, "delete all feedback failed", err, nil)
302
        HandleAppError(c, err)
303
        return
304
    }
305

306
    c.JSON(http.StatusOK, gin.H{"deleted_count": count})
307
}
308

309
// CreateLinearIssueResponse represents the response for creating a Linear issue
310
type CreateLinearIssueResponse struct {
311
    IssueID  string `json:"issue_id"`
312
    IssueURL string `json:"issue_url"`
313
    Title    string `json:"title"`
314
}
315

316
// CreateLinearIssue handles POST /v1/admin/backend/feedback/:id/linear-issue.
317
3x
func (h *FeedbackHandler) CreateLinearIssue(c *gin.Context) {
318
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "create_linear_issue")
319
3x
    defer observability.FinishSpan(span, nil)
320
3x

321
3x
    if h.linearService == nil {
322
        HandleAppError(c, contextutils.NewAppError(
323
            contextutils.ErrorCodeServiceUnavailable,
324
            contextutils.SeverityError,
325
            "Linear integration is not available",
326
            "",
327
        ))
328
        return
329
    }
330

331
3x
    id, err := strconv.Atoi(c.Param("id"))
332
3x
    if err != nil {
333
        HandleAppError(c, contextutils.ErrInvalidFormat)
334
        return
335
    }
336

337
    // Get feedback by ID
338
3x
    feedback, err := h.feedbackService.GetFeedbackByID(ctx, id)
339
3x
    if err != nil {
340
1x
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
341
1x
            HandleAppError(c, contextutils.ErrRecordNotFound)
342
1x
            return
343
1x
        }
344
        h.logger.Error(ctx, "get feedback failed", err, nil)
345
        HandleAppError(c, err)
346
        return
347
    }
348

349
    // Format title - only include feedback type and number
350
2x
    title := fmt.Sprintf("[Feedback #%d] %s", feedback.ID, getTypeLabel(feedback.FeedbackType))
351
2x

352
2x
    // Get username and user for metadata
353
2x
    username := fmt.Sprintf("User %d", feedback.UserID)
354
2x
    var user *models.User
355
2x
    if h.userService != nil {
356
2x
        user, err = h.userService.GetUserByID(ctx, feedback.UserID)
357
2x
        if err == nil && user != nil {
358
2x
            username = user.Username
359
2x
        }
360
    }
361

362
    // Build description with feedback details
363
2x
    var descriptionBuilder strings.Builder
364
2x
    descriptionBuilder.WriteString(feedback.FeedbackText)
365
2x
    descriptionBuilder.WriteString("\n\n")
366
2x

367
2x
    descriptionBuilder.WriteString("### Metadata\n\n")
368
2x
    descriptionBuilder.WriteString(fmt.Sprintf("- **Type**: %s\n", getTypeLabel(feedback.FeedbackType)))
369
2x
    descriptionBuilder.WriteString(fmt.Sprintf("- **Status**: %s\n", feedback.Status))
370
2x
    descriptionBuilder.WriteString(fmt.Sprintf("- **User ID**: %d\n", feedback.UserID))
371
2x
    descriptionBuilder.WriteString(fmt.Sprintf("- **Username**: %s\n", username))
372
2x
    descriptionBuilder.WriteString(fmt.Sprintf("- **Feedback ID**: %d\n", feedback.ID))
373
2x
    // Format created timestamp in user's timezone
374
2x
    createdFormatted := feedback.CreatedAt.Format("January 2, 2006 at 3:04 PM")
375
2x
    timezoneLabel := "UTC"
376
2x
    if h.userService != nil {
377
2x
        if formatted, tz, err := contextutils.FormatTimeInUserTimezone(ctx, feedback.UserID, feedback.CreatedAt, "January 2, 2006 at 3:04 PM", h.userService.GetUserByID); err == nil {
378
2x
            createdFormatted = formatted
379
2x
            timezoneLabel = tz
380
2x
        }
381
    }
382
2x
    descriptionBuilder.WriteString(fmt.Sprintf("- **Created**: %s (%s)\n", createdFormatted, timezoneLabel))
383
2x

384
2x
    if feedback.AdminNotes.Valid && feedback.AdminNotes.String != "" {
385
        descriptionBuilder.WriteString(fmt.Sprintf("- **Admin Notes**: %s\n", feedback.AdminNotes.String))
386
    }
387

388
    // Add context data if available
389
2x
    if len(feedback.ContextData) > 0 {
390
        descriptionBuilder.WriteString("\n### Context Data\n\n")
391
        for key, value := range feedback.ContextData {
392
            switch key {
393
            case "page_url":
394
                // Handle page_url specially - make it a full URL if it's a relative path
395
                pageURL := fmt.Sprintf("%v", value)
396
                if strings.HasPrefix(pageURL, "/") {
397
                    // It's a relative path, construct full URL
398
                    // Try to get base URL from config first
399
                    baseURL := ""
400
                    if h.config != nil && h.config.Server.AppBaseURL != "" {
401
                        baseURL = h.config.Server.AppBaseURL
402
                    }
403
                    // Fallback to request headers if config not available
404
                    if baseURL == "" {
405
                        baseURL = c.Request.Header.Get("Origin")
406
                    }
407
                    if baseURL == "" {
408
                        baseURL = c.Request.Header.Get("Referer")
409
                        if baseURL != "" {
410
                            // Extract base URL from referer (protocol + host)
411
                            // Find the first "/" after the protocol
412
                            if schemeIdx := strings.Index(baseURL, "://"); schemeIdx > 0 {
413
                                if pathIdx := strings.Index(baseURL[schemeIdx+3:], "/"); pathIdx > 0 {
414
                                    baseURL = baseURL[:schemeIdx+3+pathIdx]
415
                                }
416
                            }
417
                        }
418
                    }
419
                    // Remove trailing slash if present
420
                    if baseURL != "" {
421
                        baseURL = strings.TrimSuffix(baseURL, "/")
422
                        descriptionBuilder.WriteString(fmt.Sprintf("- **%s**: %s%s\n", key, baseURL, pageURL))
423
                    } else {
424
                        // If we can't determine base URL, just use the relative path
425
                        descriptionBuilder.WriteString(fmt.Sprintf("- **%s**: %s\n", key, pageURL))
426
                    }
427
                } else {
428
                    descriptionBuilder.WriteString(fmt.Sprintf("- **%s**: %s\n", key, pageURL))
429
                }
430
            case "timestamp":
431
                // Format timestamp as human readable in user's timezone
432
                if tsStr, ok := value.(string); ok {
433
                    if ts, err := time.Parse(time.RFC3339, tsStr); err == nil {
434
                        // Convert to user's timezone
435
                        formatted := ts.Format("January 2, 2006 at 3:04 PM")
436
                        timezoneLabel := "UTC"
437
                        if h.userService != nil {
438
                            if fmtTime, tz, err := contextutils.FormatTimeInUserTimezone(ctx, feedback.UserID, ts, "January 2, 2006 at 3:04 PM", h.userService.GetUserByID); err == nil {
439
                                formatted = fmtTime
440
                                timezoneLabel = tz
441
                            }
442
                        }
443
                        descriptionBuilder.WriteString(fmt.Sprintf("- **%s**: %s (%s)\n", key, formatted, timezoneLabel))
444
                    } else {
445
                        descriptionBuilder.WriteString(fmt.Sprintf("- **%s**: %v\n", key, value))
446
                    }
447
                } else {
448
                    descriptionBuilder.WriteString(fmt.Sprintf("- **%s**: %v\n", key, value))
449
                }
450
            default:
451
                descriptionBuilder.WriteString(fmt.Sprintf("- **%s**: %v\n", key, value))
452
            }
453
        }
454
    }
455

456
    // Add screenshot - embed as base64 data URI in markdown if available
457
2x
    if feedback.ScreenshotURL.Valid && feedback.ScreenshotURL.String != "" {
458
        descriptionBuilder.WriteString("\n### Screenshot\n\n")
459
        descriptionBuilder.WriteString(fmt.Sprintf("![Screenshot](%s)\n", feedback.ScreenshotURL.String))
460
    } else if feedback.ScreenshotData.Valid && feedback.ScreenshotData.String != "" {
461
        descriptionBuilder.WriteString("\n### Screenshot\n\n")
462
        // Embed screenshot as base64 data URI
463
        screenshotData := feedback.ScreenshotData.String
464
        // Ensure it has the data URI prefix
465
        if !strings.HasPrefix(screenshotData, "data:") {
466
            screenshotData = "data:image/png;base64," + screenshotData
467
        }
468
        descriptionBuilder.WriteString(fmt.Sprintf("![Screenshot](%s)\n", screenshotData))
469
    }
470

471
2x
    descriptionBuilder.WriteString("\n---\n*Created from Quiz Admin Feedback Reports*")
472
2x

473
2x
    description := descriptionBuilder.String()
474
2x

475
2x
    // Determine labels based on feedback type
476
2x
    var labels []string
477
2x
    switch feedback.FeedbackType {
478
2x
    case "bug":
479
2x
        labels = []string{"Bug"}
480
    case "feature_request":
481
        labels = []string{"Feature"}
482
    case "improvement":
483
        labels = []string{"Improvement"}
484
    }
485

486
    // Create Linear issue (use config defaults for team and project)
487
2x
    result, err := h.linearService.CreateIssue(ctx, title, description, "", "", labels, "")
488
2x
    if err != nil {
489
1x
        h.logger.Error(ctx, "create linear issue failed", err, nil)
490
1x
        HandleAppError(c, err)
491
1x
        return
492
1x
    }
493

494
1x
    response := CreateLinearIssueResponse{
495
1x
        IssueID:  result.IssueID,
496
1x
        IssueURL: result.IssueURL,
497
1x
        Title:    result.Title,
498
1x
    }
499
1x

500
1x
    c.JSON(http.StatusOK, response)
501
}
502

503
// getTypeLabel converts feedback type to human-readable label
504
4x
func getTypeLabel(feedbackType string) string {
505
4x
    switch feedbackType {
506
4x
    case "bug":
507
4x
        return "Bug Report"
508
    case "feature_request":
509
        return "Feature Request"
510
    case "general":
511
        return "General Feedback"
512
    case "improvement":
513
        return "Improvement"
514
    default:
515
        return feedbackType
516
    }
517
}
518


			
quizapp internal handlers worker_admin_handler.go
100.0%
Statements
20/20
1
package handlers
2

3
import (
4
    "net/http"
5
    "strconv"
6
    "strings"
7

8
    "github.com/gin-gonic/gin"
9
)
10

11
// ParsePagination parses standard pagination query params from the request.
12
// It enforces bounds and applies defaults when values are missing or invalid.
13
14x
func ParsePagination(c *gin.Context, defaultPage, defaultSize, maxSize int) (int, int) {
14
14x
    pageStr := c.DefaultQuery("page", strconv.Itoa(defaultPage))
15
14x
    sizeStr := c.DefaultQuery("page_size", strconv.Itoa(defaultSize))
16
14x

17
14x
    page, err := strconv.Atoi(pageStr)
18
14x
    if err != nil || page < 1 {
19
1x
        page = defaultPage
20
1x
    }
21

22
14x
    size, err := strconv.Atoi(sizeStr)
23
14x
    if err != nil || size < 1 {
24
1x
        size = defaultSize
25
1x
    }
26
14x
    if size > maxSize {
27
1x
        size = maxSize
28
1x
    }
29

30
14x
    return page, size
31
}
32

33
// ParseFilters returns a map of non-empty trimmed query params for the given keys.
34
8x
func ParseFilters(c *gin.Context, keys ...string) map[string]string {
35
8x
    filters := make(map[string]string, len(keys))
36
8x
    for _, key := range keys {
37
31x
        if val := strings.TrimSpace(c.Query(key)); val != "" {
38
10x
            filters[key] = val
39
10x
        }
40
    }
41
8x
    return filters
42
}
43

44
// WritePaginated standardizes paginated responses with a flexible items key, pagination block, and optional extras.
45
// It preserves existing API response shapes by allowing the caller to specify the items key.
46
1x
func WritePaginated(c *gin.Context, itemsKey string, items, pagination any, extra gin.H) {
47
1x
    response := gin.H{
48
1x
        itemsKey:     items,
49
1x
        "pagination": pagination,
50
1x
    }
51
1x
    for k, v := range extra {
52
1x
        response[k] = v
53
1x
    }
54
1x
    c.JSON(http.StatusOK, response)
55
}
56


			
quizapp internal handlers worker_admin_handler.go
52.2%
Statements
291/558
1
package handlers
2

3
import (
4
    "context"
5
    "encoding/json"
6
    "fmt"
7
    "io"
8
    "math/rand"
9
    "net/http"
10
    "strconv"
11
    "strings"
12
    "time"
13

14
    "quizapp/internal/api"
15
    "quizapp/internal/models"
16
    "quizapp/internal/observability"
17
    "quizapp/internal/services"
18
    contextutils "quizapp/internal/utils"
19

20
    "quizapp/internal/config"
21

22
    "github.com/gin-gonic/gin"
23
    "go.opentelemetry.io/otel/attribute"
24
)
25

26
// QuizHandler handles quiz-related HTTP requests including questions and answers
27
type QuizHandler struct {
28
    userService     services.UserServiceInterface
29
    questionService services.QuestionServiceInterface
30
    aiService       services.AIServiceInterface
31
    learningService services.LearningServiceInterface
32
    workerService   services.WorkerServiceInterface
33
    hintService     services.GenerationHintServiceInterface
34
    usageStatsSvc   services.UsageStatsServiceInterface
35
    cfg             *config.Config
36
    logger          *observability.Logger
37
}
38

39
// NewQuizHandler creates a new QuizHandler
40
func NewQuizHandler(
41
    userService services.UserServiceInterface,
42
    questionService services.QuestionServiceInterface,
43
    aiService services.AIServiceInterface,
44
    learningService services.LearningServiceInterface,
45
    workerService services.WorkerServiceInterface,
46
    hintService services.GenerationHintServiceInterface,
47
    usageStatsSvc services.UsageStatsServiceInterface,
48
    config *config.Config,
49
    logger *observability.Logger,
50
14x
) *QuizHandler {
51
14x
    return &QuizHandler{
52
14x
        userService:     userService,
53
14x
        questionService: questionService,
54
14x
        aiService:       aiService,
55
14x
        learningService: learningService,
56
14x
        workerService:   workerService,
57
14x
        hintService:     hintService,
58
14x
        usageStatsSvc:   usageStatsSvc,
59
14x
        cfg:             config,
60
14x
        logger:          logger,
61
14x
    }
62
14x
}
63

64
// Deprecated: use GetUserIDFromSession in session.go
65
5x
func (h *QuizHandler) getUserIDFromSession(c *gin.Context) (int, bool) {
66
5x
    return GetUserIDFromSession(c)
67
5x
}
68

69
// GetQuestion handles requests for quiz questions
70
8x
func (h *QuizHandler) GetQuestion(c *gin.Context) {
71
8x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_question")
72
8x
    defer observability.FinishSpan(span, nil)
73
8x

74
8x
    userID, exists := GetUserIDFromSession(c)
75
8x
    if !exists {
76
        HandleAppError(c, contextutils.ErrUnauthorized)
77
        return
78
    }
79

80
    // Add span attributes for observability
81
8x
    span.SetAttributes(observability.AttributeUserID(userID))
82
8x

83
8x
    // Check if a specific question ID is requested
84
8x
    questionIDStr := c.Param("id")
85
8x
    if questionIDStr != "" {
86
5x
        span.SetAttributes(attribute.String("question.id", questionIDStr))
87
5x
        h.getSpecificQuestion(c, userID, questionIDStr)
88
5x
        return
89
5x
    }
90

91
3x
    h.getNextQuestion(c, userID)
92
}
93

94
// getSpecificQuestion improves error handling with centralized utilities
95
5x
func (h *QuizHandler) getSpecificQuestion(c *gin.Context, userID int, questionIDStr string) {
96
5x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_specific_question",
97
5x
        observability.AttributeUserID(userID),
98
5x
        attribute.String("question.id_str", questionIDStr),
99
5x
    )
100
5x
    defer observability.FinishSpan(span, nil)
101
5x

102
5x
    questionID, err := strconv.Atoi(questionIDStr)
103
5x
    if err != nil {
104
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
105
1x
            contextutils.ErrorCodeInvalidInput,
106
1x
            contextutils.SeverityWarn,
107
1x
            "Invalid question ID format",
108
1x
            "Question ID must be a valid integer",
109
1x
            err,
110
1x
        ))
111
1x
        return
112
1x
    }
113

114
4x
    questionWithStats, err := h.questionService.GetQuestionWithStats(ctx, questionID)
115
4x
    if err != nil {
116
        h.logger.Error(ctx, "Failed to get question with stats", err, map[string]interface{}{
117
            "question_id": questionID,
118
            "user_id":     userID,
119
        })
120
        HandleAppError(c, contextutils.WrapError(err, "failed to get question with stats"))
121
        return
122
    }
123

124
    // Convert and hide sensitive information
125
4x
    apiQuestion, err := convertQuestionToAPI(ctx, questionWithStats.Question)
126
4x
    if err != nil {
127
        h.logger.Error(ctx, "Failed to convert question to API", err, map[string]interface{}{
128
            "question_id": questionID,
129
            "user_id":     userID,
130
        })
131
        HandleAppError(c, contextutils.WrapError(err, "failed to convert question"))
132
        return
133
    }
134
4x
    apiQuestion.Explanation = nil // Hide explanation
135
4x

136
4x
    // Add response statistics to the API question
137
4x
    apiQuestion.CorrectCount = &questionWithStats.CorrectCount
138
4x
    apiQuestion.IncorrectCount = &questionWithStats.IncorrectCount
139
4x
    apiQuestion.TotalResponses = &questionWithStats.TotalResponses
140
4x

141
4x
    // Get user-specific confidence level if available
142
4x
    confidenceLevel, err := h.learningService.GetUserQuestionConfidenceLevel(ctx, userID, questionID)
143
4x
    if err != nil {
144
        h.logger.Warn(ctx, "Failed to get user confidence level", map[string]interface{}{
145
            "error":       err.Error(),
146
            "question_id": questionID,
147
            "user_id":     userID,
148
        })
149
        // Don't fail the request, just continue without confidence level
150
    } else if confidenceLevel != nil {
151
        apiQuestion.ConfidenceLevel = confidenceLevel
152
1x
    }
153

154
4x
    c.JSON(http.StatusOK, apiQuestion)
155
}
156

157
// getNextQuestion improves error handling with centralized utilities
158
3x
func (h *QuizHandler) getNextQuestion(c *gin.Context, userID int) {
159
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_next_question",
160
3x
        observability.AttributeUserID(userID),
161
3x
    )
162
3x
    defer observability.FinishSpan(span, nil)
163
3x

164
3x
    user, err := h.userService.GetUserByID(ctx, userID)
165
3x
    if err != nil {
166
        h.logger.Error(ctx, "Failed to get user by ID", err, map[string]interface{}{
167
            "user_id": userID,
168
        })
169
        HandleAppError(c, contextutils.WrapError(err, "failed to get user by ID"))
170
        return
171
    }
172
3x
    if user == nil {
173
        span.SetAttributes(attribute.String("error.type", "user_nil"))
174
        HandleAppError(c, contextutils.ErrRecordNotFound)
175
        return
176
    }
177

178
    // Check if user has required preferences set
179
3x
    if !user.PreferredLanguage.Valid || user.PreferredLanguage.String == "" {
180
        span.SetAttributes(attribute.String("error.type", "missing_language_preference"))
181
        HandleAppError(c, contextutils.NewAppErrorWithCause(
182
            contextutils.ErrorCodeMissingRequired,
183
            contextutils.SeverityWarn,
184
            "Language preference not set",
185
            "Please set your preferred language in settings",
186
            nil,
187
        ))
188
        return
189
    }
190

191
3x
    if !user.CurrentLevel.Valid || user.CurrentLevel.String == "" {
192
        span.SetAttributes(attribute.String("error.type", "missing_level_preference"))
193
        HandleAppError(c, contextutils.NewAppErrorWithCause(
194
            contextutils.ErrorCodeMissingRequired,
195
            contextutils.SeverityWarn,
196
            "Level preference not set",
197
            "Please set your current level in settings",
198
            nil,
199
        ))
200
        return
201
    }
202

203
3x
    language := c.DefaultQuery("language", user.PreferredLanguage.String)
204
3x
    level := c.DefaultQuery("level", user.CurrentLevel.String)
205
3x

206
3x
    // Handle question type selection based on query parameters
207
3x
    var qType models.QuestionType
208
3x
    requestedTypes := c.Query("type")
209
3x
    strictTypeRequested := false
210
3x

211
3x
    if requestedTypes != "" {
212
1x
        strictTypeRequested = true
213
1x
        types := strings.Split(requestedTypes, ",")
214
1x
        // Use the first valid type from the list
215
1x
        for _, t := range types {
216
1x
            if t = strings.TrimSpace(t); t != "" {
217
1x
                qType = models.QuestionType(t)
218
1x
                break
219
            }
220
        }
221
2x
    } else {
222
2x
        // Check if we need to exclude certain types (comma-separated list)
223
2x
        excludeTypes := c.Query("exclude_type")
224
2x
        if excludeTypes != "" {
225
            excludeList := strings.Split(excludeTypes, ",")
226
            var excludeSet []models.QuestionType
227
            for _, t := range excludeList {
228
                if t = strings.TrimSpace(t); t != "" {
229
                    excludeSet = append(excludeSet, models.QuestionType(t))
230
                }
231
            }
232
            qType = h.selectRandomQuestionTypeExcluding(excludeSet...)
233
2x
        } else {
234
2x
            // Default random selection
235
2x
            qType = h.selectRandomQuestionType()
236
2x
        }
237
    }
238

239
    // Add span attributes for observability
240
3x
    span.SetAttributes(
241
3x
        attribute.String("language", language),
242
3x
        attribute.String("level", level),
243
3x
        attribute.String("question.type", string(qType)),
244
3x
        attribute.Bool("strict.type.requested", strictTypeRequested),
245
3x
    )
246
3x

247
3x
    // Get next question with fallback logic
248
3x
    questionWithStats, err := h.questionService.GetNextQuestion(ctx, userID, language, level, qType)
249
3x
    if err != nil {
250
        h.logger.Error(ctx, "Failed to get next question", err, map[string]interface{}{
251
            "user_id":       userID,
252
            "language":      language,
253
            "level":         level,
254
            "question_type": string(qType),
255
        })
256

257
        // Fallback: try without question type if strict type was requested
258
        if strictTypeRequested {
259
            h.logger.Info(ctx, "Attempting fallback without question type", map[string]interface{}{
260
                "user_id":  userID,
261
                "language": language,
262
                "level":    level,
263
            })
264
            questionWithStats, err = h.questionService.GetNextQuestion(ctx, userID, language, level, "")
265
            if err != nil {
266
                h.logger.Error(ctx, "Fallback also failed", err, map[string]interface{}{
267
                    "user_id":  userID,
268
                    "language": language,
269
                    "level":    level,
270
                })
271
                HandleAppError(c, contextutils.ErrNoQuestionsAvailable)
272
                return
273
            }
274
        } else {
275
            HandleAppError(c, contextutils.ErrNoQuestionsAvailable)
276
            return
277
        }
278
    }
279

280
    // Check if we got a valid question
281
3x
    if questionWithStats == nil || questionWithStats.Question == nil {
282
3x
        h.logger.Error(ctx, "GetNextQuestion returned nil question", nil, map[string]interface{}{
283
3x
            "user_id":       userID,
284
3x
            "language":      language,
285
3x
            "level":         level,
286
3x
            "question_type": string(qType),
287
3x
        })
288
3x
        // If the user strictly requested a type, record a generation hint with short TTL
289
3x
        if strictTypeRequested && h.hintService != nil && qType != "" {
290
1x
            // Best-effort; do not fail the request if hint upsert fails
291
1x
            _ = h.hintService.UpsertHint(ctx, userID, language, level, qType, 10*time.Minute)
292
1x
        }
293
3x
        c.JSON(http.StatusAccepted, api.GeneratingResponse{
294
3x
            Status:  stringPtr("generating"),
295
3x
            Message: stringPtr("No questions available. Prioritizing your requested question type. Please try again shortly."),
296
3x
        })
297
3x
        return
298
    }
299

300
    // Convert to API format and hide sensitive information
301
    apiQuestion, err := convertQuestionToAPI(ctx, questionWithStats.Question)
302
    if err != nil {
303
        h.logger.Error(ctx, "Failed to convert question to API", err, map[string]interface{}{
304
            "user_id":       userID,
305
            "language":      language,
306
            "level":         level,
307
            "question_type": string(qType),
308
        })
309
        HandleAppError(c, contextutils.WrapError(err, "failed to convert question"))
310
        return
311
    }
312
    apiQuestion.Explanation = nil // Hide explanation
313

314
    // Add response statistics to the API question
315
    apiQuestion.CorrectCount = &questionWithStats.CorrectCount
316
    apiQuestion.IncorrectCount = &questionWithStats.IncorrectCount
317
    apiQuestion.TotalResponses = &questionWithStats.TotalResponses
318

319
    // Add confidence level if available
320
    if questionWithStats.ConfidenceLevel != nil {
321
        apiQuestion.ConfidenceLevel = questionWithStats.ConfidenceLevel
322
    }
323

324
    c.JSON(http.StatusOK, apiQuestion)
325
}
326

327
// SubmitAnswer improves error handling with centralized utilities
328
15x
func (h *QuizHandler) SubmitAnswer(c *gin.Context) {
329
15x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "submit_answer")
330
15x
    defer observability.FinishSpan(span, nil)
331
15x

332
15x
    userID, exists := GetUserIDFromSession(c)
333
15x
    if !exists {
334
        HandleAppError(c, contextutils.ErrUnauthorized)
335
        return
336
    }
337

338
15x
    var req api.AnswerRequest
339
15x
    if err := c.ShouldBindJSON(&req); err != nil {
340
1x
        h.logger.Error(ctx, "Invalid answer request format", err, map[string]interface{}{
341
1x
            "user_id": userID,
342
1x
        })
343
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
344
1x
            contextutils.ErrorCodeInvalidInput,
345
1x
            contextutils.SeverityWarn,
346
1x
            "Invalid request format",
347
1x
            "",
348
1x
            err,
349
1x
        ))
350
1x
        return
351
1x
    }
352

353
    // Get the question
354
14x
    question, err := h.questionService.GetQuestionByID(ctx, int(req.QuestionId))
355
14x
    if err != nil {
356
        h.logger.Error(ctx, "Failed to get question by ID", err, map[string]interface{}{
357
            "question_id": req.QuestionId,
358
            "user_id":     userID,
359
        })
360
        HandleAppError(c, contextutils.ErrQuestionNotFound)
361
        return
362
    }
363

364
    // Check if answer is correct
365
14x
    isCorrect := int(req.UserAnswerIndex) == question.CorrectAnswer
366
14x

367
14x
    // Record user response
368
14x
    responseTimeMs := 0
369
14x
    if req.ResponseTimeMs != nil {
370
        responseTimeMs = int(*req.ResponseTimeMs)
371
    }
372

373
    // Use priority-aware recording to ensure priority scores are updated
374
    // Store the user's answer index for future reference
375
14x
    if err := h.learningService.RecordAnswerWithPriority(ctx, userID, int(req.QuestionId), int(req.UserAnswerIndex), isCorrect, responseTimeMs); err != nil {
376
        h.logger.Error(ctx, "Failed to record user response", err, map[string]interface{}{
377
            "user_id":     userID,
378
            "question_id": req.QuestionId,
379
        })
380
        HandleAppError(c, contextutils.WrapError(err, "failed to record response"))
381
        return
382
    }
383

384
    // Prepare response
385
    // Get the user's answer text from the question options
386
14x
    userAnswerText := ""
387
14x
    if optionsRaw, ok := question.Content["options"]; ok {
388
14x
        if options, ok := optionsRaw.([]interface{}); ok {
389
14x
            if int(req.UserAnswerIndex) >= 0 && int(req.UserAnswerIndex) < len(options) {
390
14x
                if optStr, ok := options[int(req.UserAnswerIndex)].(string); ok {
391
14x
                    userAnswerText = optStr
392
14x
                }
393
            }
394
        }
395
    }
396

397
14x
    answerResponse := &api.AnswerResponse{
398
14x
        IsCorrect:          &isCorrect,
399
14x
        UserAnswer:         &userAnswerText,
400
14x
        UserAnswerIndex:    &req.UserAnswerIndex,
401
14x
        Explanation:        &question.Explanation,
402
14x
        CorrectAnswerIndex: &question.CorrectAnswer,
403
14x
    }
404
14x

405
14x
    c.JSON(http.StatusOK, answerResponse)
406
14x

407
14x
    // Add span attributes for observability
408
14x
    span.SetAttributes(
409
14x
        attribute.Int("user.id", userID),
410
14x
        attribute.Int("question.id", int(req.QuestionId)),
411
14x
        attribute.Bool("answer.is_correct", isCorrect),
412
14x
        attribute.Int("response.time_ms", responseTimeMs),
413
14x
    )
414
}
415

416
// GetProgress improves error handling with centralized utilities
417
18x
func (h *QuizHandler) GetProgress(c *gin.Context) {
418
18x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_progress")
419
18x
    defer observability.FinishSpan(span, nil)
420
18x

421
18x
    userID, exists := GetUserIDFromSession(c)
422
18x
    if !exists {
423
        HandleAppError(c, contextutils.ErrUnauthorized)
424
        return
425
    }
426

427
18x
    span.SetAttributes(observability.AttributeUserID(userID))
428
18x

429
18x
    progress, err := h.learningService.GetUserProgress(ctx, userID)
430
18x
    if err != nil {
431
        h.logger.Error(ctx, "Failed to get user progress", err, map[string]interface{}{
432
            "user_id": userID,
433
        })
434
        HandleAppError(c, contextutils.WrapError(err, "failed to get progress"))
435
        return
436
    }
437

438
    // Get worker status information
439
18x
    workerStatus, err := h.getWorkerStatusForUser(ctx, userID)
440
18x
    if err != nil {
441
        h.logger.Warn(ctx, "Failed to get worker status for user", map[string]interface{}{
442
            "user_id": userID,
443
            "error":   err.Error(),
444
        })
445
        // Don't fail the entire request, just log the warning
446
    }
447

448
    // Get learning preferences
449
18x
    learningPrefs, err := h.learningService.GetUserLearningPreferences(ctx, userID)
450
18x
    if err != nil {
451
        h.logger.Warn(ctx, "Failed to get learning preferences for user", map[string]interface{}{
452
            "user_id": userID,
453
            "error":   err.Error(),
454
        })
455
        // Don't fail the entire request, just log the warning
456
    }
457

458
    // Get priority insights
459
18x
    priorityInsights, err := h.getPriorityInsightsForUser(ctx, userID)
460
18x
    if err != nil {
461
        h.logger.Warn(ctx, "Failed to get priority insights for user", map[string]interface{}{
462
            "user_id": userID,
463
            "error":   err.Error(),
464
        })
465
        // Don't fail the entire request, just log the warning
466
    }
467

468
    // Get generation focus information
469
18x
    generationFocus, err := h.getGenerationFocusForUser(ctx, userID)
470
18x
    if err != nil {
471
        h.logger.Warn(ctx, "Failed to get generation focus for user", map[string]interface{}{
472
            "user_id": userID,
473
            "error":   err.Error(),
474
        })
475
        // Don't fail the entire request, just log the warning
476
    }
477

478
    // Get high priority topics
479
18x
    highPriorityTopics, err := h.getHighPriorityTopicsForUser(ctx, userID)
480
18x
    if err != nil {
481
        h.logger.Warn(ctx, "Failed to get high priority topics for user", map[string]interface{}{
482
            "user_id": userID,
483
            "error":   err.Error(),
484
        })
485
        // Don't fail the entire request, just log the warning
486
    }
487

488
    // Get gap analysis
489
18x
    gapAnalysis, err := h.getGapAnalysisForUser(ctx, userID)
490
18x
    if err != nil {
491
        h.logger.Warn(ctx, "Failed to get gap analysis for user", map[string]interface{}{
492
            "user_id": userID,
493
            "error":   err.Error(),
494
        })
495
        // Don't fail the entire request, just log the warning
496
    }
497

498
    // Get priority distribution
499
18x
    priorityDistribution, err := h.getPriorityDistributionForUser(ctx, userID)
500
18x
    if err != nil {
501
        h.logger.Warn(ctx, "Failed to get priority distribution for user", map[string]interface{}{
502
            "user_id": userID,
503
            "error":   err.Error(),
504
        })
505
        // Don't fail the entire request, just log the warning
506
    }
507

508
    // Convert models.UserProgress to api.UserProgress
509
18x
    apiProgress := convertUserProgressToAPI(ctx, progress, userID, h.userService.GetUserByID)
510
18x

511
18x
    // Add worker-related information
512
18x
    if workerStatus != nil {
513
18x
        apiProgress.WorkerStatus = workerStatus
514
18x
    }
515
18x
    if learningPrefs != nil {
516
18x
        apiProgress.LearningPreferences = convertLearningPreferencesToAPI(learningPrefs)
517
18x
    }
518
18x
    if priorityInsights != nil {
519
18x
        apiProgress.PriorityInsights = priorityInsights
520
18x
    }
521
18x
    if generationFocus != nil {
522
18x
        apiProgress.GenerationFocus = generationFocus
523
18x
    }
524
18x
    if highPriorityTopics != nil {
525
18x
        apiProgress.HighPriorityTopics = &highPriorityTopics
526
18x
    }
527
18x
    if gapAnalysis != nil {
528
18x
        apiProgress.GapAnalysis = &gapAnalysis
529
18x
    }
530
18x
    if priorityDistribution != nil {
531
18x
        apiProgress.PriorityDistribution = &priorityDistribution
532
18x
    }
533

534
18x
    c.JSON(http.StatusOK, apiProgress)
535
}
536

537
// GetAITokenUsage returns AI token usage statistics for the authenticated user
538
func (h *QuizHandler) GetAITokenUsage(c *gin.Context) {
539
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_ai_token_usage")
540
    defer observability.FinishSpan(span, nil)
541

542
    userID, exists := GetUserIDFromSession(c)
543
    if !exists {
544
        span.SetAttributes(attribute.String("error", "no_user_session"))
545
        HandleAppError(c, contextutils.WrapError(contextutils.ErrUnauthorized, "user not authenticated"))
546
        return
547
    }
548
    span.SetAttributes(observability.AttributeUserID(userID))
549

550
    startDateStr := c.Query("startDate")
551
    if startDateStr == "" {
552
        span.SetAttributes(attribute.String("error", "missing_start_date"))
553
        HandleAppError(c, contextutils.WrapError(contextutils.ErrInvalidInput, "startDate parameter is required"))
554
        return
555
    }
556

557
    endDateStr := c.Query("endDate")
558
    if endDateStr == "" {
559
        span.SetAttributes(attribute.String("error", "missing_end_date"))
560
        HandleAppError(c, contextutils.WrapError(contextutils.ErrInvalidInput, "endDate parameter is required"))
561
        return
562
    }
563

564
    startDate, err := time.Parse("2006-01-02", startDateStr)
565
    if err != nil {
566
        span.SetAttributes(attribute.String("error", "invalid_start_date"))
567
        HandleAppError(c, contextutils.WrapErrorf(contextutils.ErrInvalidInput, "invalid startDate format: %v", err))
568
        return
569
    }
570

571
    endDate, err := time.Parse("2006-01-02", endDateStr)
572
    if err != nil {
573
        span.SetAttributes(attribute.String("error", "invalid_end_date"))
574
        HandleAppError(c, contextutils.WrapErrorf(contextutils.ErrInvalidInput, "invalid endDate format: %v", err))
575
        return
576
    }
577

578
    // Get usage stats
579
    stats, err := h.usageStatsSvc.GetUserAITokenUsageStats(ctx, userID, startDate, endDate)
580
    if err != nil {
581
        h.logger.Error(ctx, "Failed to get user AI token usage stats", err, map[string]any{
582
            "user_id":    userID,
583
            "start_date": startDateStr,
584
            "end_date":   endDateStr,
585
        })
586
        HandleAppError(c, contextutils.WrapError(err, "failed to get AI token usage stats"))
587
        return
588
    }
589

590
    c.JSON(http.StatusOK, stats)
591
}
592

593
// GetAITokenUsageDaily returns daily aggregated AI token usage for the authenticated user
594
func (h *QuizHandler) GetAITokenUsageDaily(c *gin.Context) {
595
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_ai_token_usage_daily")
596
    defer observability.FinishSpan(span, nil)
597

598
    userID, exists := GetUserIDFromSession(c)
599
    if !exists {
600
        span.SetAttributes(attribute.String("error", "no_user_session"))
601
        HandleAppError(c, contextutils.WrapError(contextutils.ErrUnauthorized, "user not authenticated"))
602
        return
603
    }
604
    span.SetAttributes(observability.AttributeUserID(userID))
605

606
    startDateStr := c.Query("startDate")
607
    if startDateStr == "" {
608
        span.SetAttributes(attribute.String("error", "missing_start_date"))
609
        HandleAppError(c, contextutils.WrapError(contextutils.ErrInvalidInput, "startDate parameter is required"))
610
        return
611
    }
612

613
    endDateStr := c.Query("endDate")
614
    if endDateStr == "" {
615
        span.SetAttributes(attribute.String("error", "missing_end_date"))
616
        HandleAppError(c, contextutils.WrapError(contextutils.ErrInvalidInput, "endDate parameter is required"))
617
        return
618
    }
619

620
    startDate, err := time.Parse("2006-01-02", startDateStr)
621
    if err != nil {
622
        span.SetAttributes(attribute.String("error", "invalid_start_date"))
623
        HandleAppError(c, contextutils.WrapErrorf(contextutils.ErrInvalidInput, "invalid startDate format: %v", err))
624
        return
625
    }
626

627
    endDate, err := time.Parse("2006-01-02", endDateStr)
628
    if err != nil {
629
        span.SetAttributes(attribute.String("error", "invalid_end_date"))
630
        HandleAppError(c, contextutils.WrapErrorf(contextutils.ErrInvalidInput, "invalid endDate format: %v", err))
631
        return
632
    }
633

634
    // Get daily usage stats
635
    stats, err := h.usageStatsSvc.GetUserAITokenUsageStatsByDay(ctx, userID, startDate, endDate)
636
    if err != nil {
637
        h.logger.Error(ctx, "Failed to get user AI token usage stats by day", err, map[string]interface{}{
638
            "user_id":    userID,
639
            "start_date": startDateStr,
640
            "end_date":   endDateStr,
641
        })
642
        HandleAppError(c, contextutils.WrapError(err, "failed to get daily AI token usage stats"))
643
        return
644
    }
645

646
    c.JSON(http.StatusOK, stats)
647
}
648

649
// GetAITokenUsageHourly returns hourly aggregated AI token usage for the authenticated user on a specific day
650
func (h *QuizHandler) GetAITokenUsageHourly(c *gin.Context) {
651
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_ai_token_usage_hourly")
652
    defer observability.FinishSpan(span, nil)
653

654
    userID, exists := GetUserIDFromSession(c)
655
    if !exists {
656
        span.SetAttributes(attribute.String("error", "no_user_session"))
657
        HandleAppError(c, contextutils.WrapError(contextutils.ErrUnauthorized, "user not authenticated"))
658
        return
659
    }
660
    span.SetAttributes(observability.AttributeUserID(userID))
661

662
    dateStr := c.Query("date")
663
    if dateStr == "" {
664
        span.SetAttributes(attribute.String("error", "missing_date"))
665
        HandleAppError(c, contextutils.WrapError(contextutils.ErrInvalidInput, "date parameter is required"))
666
        return
667
    }
668

669
    date, err := time.Parse("2006-01-02", dateStr)
670
    if err != nil {
671
        span.SetAttributes(attribute.String("error", "invalid_date"))
672
        HandleAppError(c, contextutils.WrapErrorf(contextutils.ErrInvalidInput, "invalid date format: %v", err))
673
        return
674
    }
675

676
    // Get hourly usage stats
677
    stats, err := h.usageStatsSvc.GetUserAITokenUsageStatsByHour(ctx, userID, date)
678
    if err != nil {
679
        h.logger.Error(ctx, "Failed to get user AI token usage stats by hour", err, map[string]interface{}{
680
            "user_id": userID,
681
            "date":    dateStr,
682
        })
683
        HandleAppError(c, contextutils.WrapError(err, "failed to get hourly AI token usage stats"))
684
        return
685
    }
686

687
    c.JSON(http.StatusOK, stats)
688
}
689

690
// ReportQuestion improves error handling with centralized utilities
691
7x
func (h *QuizHandler) ReportQuestion(c *gin.Context) {
692
7x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "report_question")
693
7x
    defer observability.FinishSpan(span, nil)
694
7x

695
7x
    userID, exists := GetUserIDFromSession(c)
696
7x
    if !exists {
697
        HandleAppError(c, contextutils.ErrUnauthorized)
698
        return
699
    }
700

701
7x
    questionIDStr := c.Param("id")
702
7x
    questionID, err := strconv.Atoi(questionIDStr)
703
7x
    if err != nil {
704
        HandleValidationError(c, "question_id", questionIDStr, "must be a valid integer")
705
        return
706
    }
707

708
    // Parse request body for report reason
709
7x
    var req struct {
710
7x
        ReportReason *string `json:"report_reason"`
711
7x
    }
712
7x

713
7x
    // Bind JSON if present (optional)
714
7x
    if err := c.ShouldBindJSON(&req); err != nil {
715
4x
        // Ignore binding errors for optional request body
716
4x
        req.ReportReason = nil
717
4x
    }
718

719
    // Get report reason, default to empty string if not provided
720
7x
    reportReason := ""
721
7x
    if req.ReportReason != nil {
722
3x
        reportReason = *req.ReportReason
723
3x
    }
724

725
7x
    span.SetAttributes(
726
7x
        observability.AttributeUserID(userID),
727
7x
        observability.AttributeQuestionID(questionID),
728
7x
    )
729
7x

730
7x
    err = h.questionService.ReportQuestion(ctx, questionID, userID, reportReason)
731
7x
    if err != nil {
732
1x
        h.logger.Error(ctx, "Failed to report question", err, map[string]interface{}{
733
1x
            "question_id": questionID,
734
1x
            "user_id":     userID,
735
1x
        })
736
1x
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
737
1x
            HandleAppError(c, contextutils.ErrQuestionNotFound)
738
1x
            return
739
1x
        }
740
        HandleAppError(c, contextutils.WrapError(err, "failed to report question"))
741
        return
742
    }
743

744
6x
    c.JSON(http.StatusOK, api.SuccessResponse{Success: true, Message: stringPtr("Question reported successfully")})
745
}
746

747
// MarkQuestionAsKnown improves error handling with centralized utilities
748
6x
func (h *QuizHandler) MarkQuestionAsKnown(c *gin.Context) {
749
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "mark_question_as_known")
750
6x
    defer observability.FinishSpan(span, nil)
751
6x

752
6x
    userID, exists := GetUserIDFromSession(c)
753
6x
    if !exists {
754
        HandleAppError(c, contextutils.ErrUnauthorized)
755
        return
756
    }
757

758
6x
    questionIDStr := c.Param("id")
759
6x
    questionID, err := strconv.Atoi(questionIDStr)
760
6x
    if err != nil {
761
1x
        HandleValidationError(c, "question_id", questionIDStr, "must be a valid integer")
762
1x
        return
763
1x
    }
764

765
    // Optional: Parse confidence level from request body
766
5x
    var req struct {
767
5x
        ConfidenceLevel *int `json:"confidence_level"`
768
5x
    }
769
5x

770
5x
    // Bind JSON if present (optional)
771
5x
    if err := c.ShouldBindJSON(&req); err != nil {
772
3x
        // Ignore binding errors for optional request body
773
3x
        req.ConfidenceLevel = nil
774
3x
    }
775

776
5x
    span.SetAttributes(
777
5x
        observability.AttributeUserID(userID),
778
5x
        observability.AttributeQuestionID(questionID),
779
5x
    )
780
5x

781
5x
    // Mark question as known with confidence level
782
5x
    err = h.learningService.MarkQuestionAsKnown(ctx, userID, questionID, req.ConfidenceLevel)
783
5x
    if err != nil {
784
        h.logger.Error(ctx, "Failed to mark question as known for user", err, map[string]interface{}{
785
            "question_id": questionID,
786
            "user_id":     userID,
787
        })
788
        if contextutils.IsError(err, contextutils.ErrQuestionNotFound) {
789
            HandleAppError(c, contextutils.ErrQuestionNotFound)
790
            return
791
        }
792
        HandleAppError(c, contextutils.WrapError(err, "failed to mark question as known"))
793
        return
794
    }
795

796
5x
    c.JSON(http.StatusOK, api.SuccessResponse{Success: true, Message: stringPtr("Question marked as known successfully")})
797
}
798

799
// ChatStream handles requests for AI-powered streaming chat about a question
800
3x
func (h *QuizHandler) ChatStream(c *gin.Context) {
801
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "chat_stream")
802
3x
    defer observability.FinishSpan(span, nil)
803
3x

804
3x
    userID, exists := h.getUserIDFromSession(c)
805
3x
    if !exists {
806
        HandleAppError(c, contextutils.ErrUnauthorized)
807
        return
808
    }
809

810
3x
    var req api.QuizChatRequest
811
3x
    if err := c.ShouldBindJSON(&req); err != nil {
812
        HandleAppError(c, contextutils.NewAppErrorWithCause(
813
            contextutils.ErrorCodeInvalidInput,
814
            contextutils.SeverityWarn,
815
            "Invalid request format",
816
            "",
817
            err,
818
        ))
819
        return
820
    }
821

822
3x
    user, err := h.userService.GetUserByID(ctx, userID)
823
3x
    if err != nil || user == nil {
824
        HandleAppError(c, contextutils.ErrRecordNotFound)
825
        return
826
    }
827

828
3x
    span.SetAttributes(
829
3x
        observability.AttributeUserID(userID),
830
3x
        attribute.String("ai.provider", user.AIProvider.String),
831
3x
        attribute.String("ai.model", user.AIModel.String),
832
3x
    )
833
3x

834
3x
    // Prepare the request for the AI service
835
3x
    aiReq := &models.AIChatRequest{
836
3x
        Language:     string(*req.Question.Language),
837
3x
        Level:        string(*req.Question.Level),
838
3x
        QuestionType: models.QuestionType(*req.Question.Type),
839
3x
        UserMessage:  req.UserMessage,
840
3x
    }
841
3x

842
3x
    if req.Question.Content != nil {
843
3x
        // For fill_blank questions, use sentence if question is not available
844
3x
        if req.Question.Content.Question != nil {
845
3x
            aiReq.Question = *req.Question.Content.Question
846
3x
        } else if req.Question.Content.Sentence != nil {
847
            aiReq.Question = *req.Question.Content.Sentence
848
        }
849
3x
        aiReq.Options = req.Question.Content.Options
850
3x
        if req.Question.Content.Passage != nil {
851
1x
            aiReq.Passage = *req.Question.Content.Passage
852
1x
        }
853
        // For vocabulary questions, use the sentence field as the passage
854
3x
        if req.Question.Content.Sentence != nil && req.Question.Type != nil && *req.Question.Type == "vocabulary" {
855
1x
            aiReq.Passage = *req.Question.Content.Sentence
856
1x
        }
857
    }
858

859
3x
    if req.AnswerContext != nil {
860
        if req.AnswerContext.UserAnswer != nil {
861
            aiReq.UserAnswer = *req.AnswerContext.UserAnswer
862
        }
863
        if req.AnswerContext.IsCorrect != nil {
864
            aiReq.IsCorrect = req.AnswerContext.IsCorrect
865
        }
866
    }
867

868
    // Include conversation history if provided
869
3x
    if req.ConversationHistory != nil {
870
        aiReq.ConversationHistory = make([]models.ChatMessage, len(*req.ConversationHistory))
871
        for i, msg := range *req.ConversationHistory {
872
            // Extract text content from the object
873
            contentText := ""
874
            if msg.Content.Text != nil {
875
                contentText = *msg.Content.Text
876
            }
877
            aiReq.ConversationHistory[i] = models.ChatMessage{
878
                Role:    msg.Role,
879
                Content: contentText,
880
            }
881
        }
882
    }
883

884
    // Create user AI configuration
885
3x
    userConfig := &models.UserAIConfig{
886
3x
        Provider: "", // will be set from user settings
887
3x
        Model:    "", // use service default
888
3x
        APIKey:   "",
889
3x
        Username: user.Username,
890
3x
    }
891
3x
    if user.AIProvider.Valid && user.AIProvider.String != "" {
892
        userConfig.Provider = user.AIProvider.String
893
    }
894
3x
    if user.AIModel.Valid && user.AIModel.String != "" {
895
        userConfig.Model = user.AIModel.String
896
    }
897
    // Use the new per-provider API key system instead of the old user.AIAPIKey field
898
3x
    var apiKeyID *int
899
3x
    if userConfig.Provider != "" {
900
        savedKey, keyID, err := h.userService.GetUserAPIKeyWithID(c.Request.Context(), userID, userConfig.Provider)
901
        if err == nil && savedKey != "" {
902
            userConfig.APIKey = savedKey
903
            apiKeyID = keyID
904
        }
905
    }
906

907
    // Set up Server-Sent Events headers
908
3x
    c.Header("Content-Type", "text/event-stream")
909
3x
    c.Header("Cache-Control", "no-cache")
910
3x
    c.Header("Connection", "keep-alive")
911
3x
    c.Header("Access-Control-Allow-Origin", "*")
912
3x
    c.Header("Access-Control-Allow-Headers", "Cache-Control")
913
3x

914
3x
    // Create a channel for streaming chunks
915
3x
    chunks := make(chan string, 10)
916
3x

917
3x
    // Use the request context to detect client disconnect
918
3x
    reqCtx := c.Request.Context()
919
3x

920
3x
    // Create a timeout context, but also watch for client disconnect
921
3x
    timeoutCtx, cancel := context.WithTimeout(reqCtx, config.QuizStreamTimeout)
922
3x
    defer cancel()
923
3x

924
3x
    // Combine both contexts - cancel if either times out or client disconnects
925
3x
    ctx, combinedCancel := context.WithCancel(timeoutCtx)
926
3x
    defer combinedCancel()
927
3x

928
3x
    // Store userID and apiKeyID in context for usage tracking
929
3x
    // This context will be used by the AI service for usage tracking
930
3x
    ctx = contextutils.WithUserID(ctx, userID)
931
3x
    if apiKeyID != nil {
932
        ctx = contextutils.WithAPIKeyID(ctx, *apiKeyID)
933
    }
934

935
    // Watch for client disconnect
936
3x
    go func() {
937
3x
        defer func() {
938
3x
            if r := recover(); r != nil {
939
                h.logger.Error(ctx, "Panic in client disconnect watcher", nil, map[string]any{
940
                    "panic": r,
941
                })
942
            }
943
        }()
944
3x
        select {
945
        case <-reqCtx.Done():
946
            combinedCancel() // Cancel if client disconnects
947
3x
        case <-ctx.Done():
948
            // Context already cancelled
949
        }
950
    }()
951

952
    // Start the AI streaming in a goroutine
953
3x
    go func() {
954
3x
        defer func() {
955
3x
            if r := recover(); r != nil {
956
                h.logger.Error(ctx, "Panic in AI streaming goroutine", nil, map[string]interface{}{
957
                    "panic": r,
958
                })
959
            }
960
3x
            close(chunks) // Close the channel when the goroutine completes
961
        }()
962
3x
        if err := h.aiService.GenerateChatResponseStream(ctx, userConfig, aiReq, chunks); err != nil {
963
3x
            h.logger.Error(ctx, "AI chat streaming failed for user", err, map[string]interface{}{
964
3x
                "user_id": contextutils.GetUserIDFromContext(ctx),
965
3x
            })
966
3x
            // Only send error if context is not cancelled (avoid sending to closed channel)
967
3x
            if ctx.Err() == nil {
968
3x
                select {
969
3x
                case chunks <- fmt.Sprintf("ERROR: %v", err):
970
                default:
971
                    // Channel full, skip sending error
972
                }
973
            }
974
        }
975
    }()
976

977
    // Stream the response chunks
978
3x
    c.Stream(func(w io.Writer) bool {
979
3x
        select {
980
3x
        case chunk, ok := <-chunks:
981
3x
            if !ok {
982
                // Channel closed, end streaming
983
                return false
984
            }
985

986
            // Handle error messages
987
3x
            if strings.HasPrefix(chunk, "ERROR: ") {
988
3x
                c.SSEvent("error", chunk[7:]) // Remove "ERROR: " prefix
989
3x
                return false
990
3x
            }
991

992
            // Marshal the chunk to JSON to ensure newlines and special characters are preserved.
993
            jsonChunk, err := json.Marshal(chunk)
994
            if err != nil {
995
                h.logger.Error(ctx, "Failed to marshal chat stream chunk to JSON", err)
996
                return true // Continue streaming, skip this chunk
997
            }
998

999
            // Send normal content chunk in proper SSE format
1000
            if _, err := fmt.Fprintf(w, "data: %s\n\n", jsonChunk); err != nil {
1001
                h.logger.Error(ctx, "Failed to write chat stream data", err)
1002
                return false
1003
            }
1004
            c.Writer.Flush()
1005
            return true
1006
        case <-ctx.Done():
1007
            c.SSEvent("error", "Request timeout")
1008
            return false
1009
        }
1010
    })
1011
}
1012

1013
// Helper methods
1014

1015
2x
func (h *QuizHandler) selectRandomQuestionType() models.QuestionType {
1016
2x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
1017
2x
    types := []models.QuestionType{
1018
2x
        models.Vocabulary,
1019
2x
        models.FillInBlank,
1020
2x
        models.QuestionAnswer,
1021
2x
        models.ReadingComprehension,
1022
2x
    }
1023
2x
    return types[rand.Intn(len(types))]
1024
2x
}
1025

1026
// selectRandomQuestionTypeExcluding returns a random question type excluding the specified types
1027
func (h *QuizHandler) selectRandomQuestionTypeExcluding(excludeTypes ...models.QuestionType) models.QuestionType {
1028
    availableTypes := []models.QuestionType{
1029
        models.Vocabulary,
1030
        models.FillInBlank,
1031
        models.QuestionAnswer,
1032
        models.ReadingComprehension,
1033
    }
1034

1035
    // Filter out excluded types
1036
    for _, excludeType := range excludeTypes {
1037
        for i, availableType := range availableTypes {
1038
            if availableType == excludeType {
1039
                availableTypes = append(availableTypes[:i], availableTypes[i+1:]...)
1040
                break
1041
            }
1042
        }
1043
    }
1044

1045
    if len(availableTypes) == 0 {
1046
        return models.Vocabulary // Default fallback
1047
    }
1048

1049
    return availableTypes[rand.Intn(len(availableTypes))]
1050
}
1051

1052
// GetWorkerStatus returns worker status and error information for the current user
1053
2x
func (h *QuizHandler) GetWorkerStatus(c *gin.Context) {
1054
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_worker_status")
1055
2x
    defer observability.FinishSpan(span, nil)
1056
2x

1057
2x
    userID, exists := h.getUserIDFromSession(c)
1058
2x
    if !exists {
1059
        HandleAppError(c, contextutils.ErrUnauthorized)
1060
        return
1061
    }
1062

1063
2x
    span.SetAttributes(observability.AttributeUserID(userID))
1064
2x

1065
2x
    // Get worker health information
1066
2x
    workerHealth, err := h.workerService.GetWorkerHealth(ctx)
1067
2x
    if err != nil {
1068
        h.logger.Error(ctx, "Failed to get worker health", err)
1069
        HandleAppError(c, contextutils.WrapError(err, "failed to get worker status"))
1070
        return
1071
    }
1072

1073
    // Check if user is paused
1074
2x
    userPaused, err := h.workerService.IsUserPaused(ctx, userID)
1075
2x
    if err != nil {
1076
        h.logger.Error(ctx, "Failed to check user pause status", err, nil)
1077
        userPaused = false // Default to not paused if check fails
1078
    }
1079

1080
    // Check if global pause is active
1081
2x
    globalPaused, err := h.workerService.IsGlobalPaused(ctx)
1082
2x
    if err != nil {
1083
        h.logger.Error(ctx, "Failed to check global pause status", err, nil)
1084
        globalPaused = false // Default to not paused if check fails
1085
    }
1086

1087
    // Extract relevant information for the user
1088
2x
    response := gin.H{
1089
2x
        "has_errors":         false,
1090
2x
        "error_message":      "",
1091
2x
        "global_paused":      globalPaused,
1092
2x
        "user_paused":        userPaused,
1093
2x
        "healthy_workers":    workerHealth["healthy_count"],
1094
2x
        "total_workers":      workerHealth["total_count"],
1095
2x
        "last_error_details": "",
1096
2x
        "worker_running":     false,
1097
2x
    }
1098
2x

1099
2x
    // Check for worker errors
1100
2x
    if workerInstances, ok := workerHealth["worker_instances"].([]map[string]interface{}); ok {
1101
2x
        for _, instance := range workerInstances {
1102
            if lastError, hasError := instance["last_run_error"]; hasError && lastError != nil {
1103
                // Only handle string type
1104
                if errorStr, ok := lastError.(string); ok && errorStr != "" {
1105
                    response["has_errors"] = true
1106
                    response["error_message"] = "Worker encountered errors during question generation"
1107
                    response["last_error_details"] = errorStr
1108
                    break
1109
                }
1110
            }
1111
            if isRunning, ok := instance["is_running"].(bool); ok && isRunning {
1112
                response["worker_running"] = true
1113
            }
1114
        }
1115
    }
1116

1117
2x
    c.JSON(http.StatusOK, response)
1118
}
1119

1120
// Helper functions for enhanced progress information
1121

1122
18x
func (h *QuizHandler) getWorkerStatusForUser(ctx context.Context, userID int) (*api.WorkerStatus, error) {
1123
18x
    // Get worker health information
1124
18x
    workerHealth, err := h.workerService.GetWorkerHealth(ctx)
1125
18x
    if err != nil {
1126
        return nil, err
1127
    }
1128

1129
    // Check if user is paused
1130
18x
    userPaused, err := h.workerService.IsUserPaused(ctx, userID)
1131
18x
    if err != nil {
1132
        userPaused = false // Default to not paused if check fails
1133
    }
1134

1135
    // Check if global pause is active
1136
18x
    globalPaused, err := h.workerService.IsGlobalPaused(ctx)
1137
18x
    if err != nil {
1138
        globalPaused = false // Default to not paused if check fails
1139
    }
1140

1141
    // Determine worker status
1142
18x
    var status api.WorkerStatusStatus
1143
18x
    var errorMessage *string
1144
18x

1145
18x
    if globalPaused {
1146
        status = api.Idle // Use idle for paused state
1147
    } else if userPaused {
1148
        status = api.Idle // Use idle for paused state
1149
    } else {
1150
18x
        status = api.Idle // Default to idle
1151
18x
        // Check for worker errors and actual activity
1152
18x
        if workerInstances, ok := workerHealth["worker_instances"].([]map[string]interface{}); ok {
1153
18x
            for _, instance := range workerInstances {
1154
                // Check for errors first - errors take priority
1155
                if lastError, hasError := instance["last_run_error"]; hasError && lastError != nil {
1156
                    if errorStr, ok := lastError.(string); ok && errorStr != "" {
1157
                        // Set status to error when there are errors
1158
                        status = api.Error
1159
                        errorMessage = &errorStr
1160
                        break
1161
                    }
1162
                }
1163

1164
                // Only check for busy status if we haven't found an error
1165
                if status != api.Error {
1166
                    // Check if worker is running AND has recent activity
1167
                    if isRunning, ok := instance["is_running"].(bool); ok && isRunning {
1168
                        // Only set to busy if the worker is actually active (not just running but idle)
1169
                        // We'll check if there's recent activity or if the worker is actively generating
1170
                        if lastHeartbeat, hasHeartbeat := instance["last_heartbeat"]; hasHeartbeat && lastHeartbeat != nil {
1171
                            if heartbeatStr, ok := lastHeartbeat.(string); ok {
1172
                                if heartbeat, err := time.Parse(time.RFC3339, heartbeatStr); err == nil {
1173
                                    // Consider busy if heartbeat is very recent (within last 30 seconds)
1174
                                    if time.Since(heartbeat) < 30*time.Second {
1175
                                        status = api.Busy
1176
                                    }
1177
                                }
1178
                            }
1179
                        }
1180
                    }
1181
                }
1182
            }
1183
        }
1184
    }
1185

1186
    // Get last heartbeat
1187
18x
    var lastHeartbeat *time.Time
1188
18x
    if workerInstances, ok := workerHealth["worker_instances"].([]map[string]interface{}); ok && len(workerInstances) > 0 {
1189
        if heartbeatStr, ok := workerInstances[0]["last_heartbeat"].(string); ok {
1190
            if heartbeat, err := time.Parse(time.RFC3339, heartbeatStr); err == nil {
1191
                lastHeartbeat = &heartbeat
1192
            }
1193
        }
1194
    }
1195

1196
18x
    return &api.WorkerStatus{
1197
18x
        Status:        &status,
1198
18x
        LastHeartbeat: formatTimePointer(lastHeartbeat),
1199
18x
        ErrorMessage:  errorMessage,
1200
18x
    }, nil
1201
}
1202

1203
18x
func (h *QuizHandler) getPriorityInsightsForUser(ctx context.Context, userID int) (*api.PriorityInsights, error) {
1204
18x
    // Get priority distribution for the user
1205
18x
    priorityDistribution, err := h.learningService.GetUserPriorityScoreDistribution(ctx, userID)
1206
18x
    if err != nil {
1207
        return nil, err
1208
    }
1209

1210
    // Extract counts from distribution
1211
18x
    highCount := 0
1212
18x
    mediumCount := 0
1213
18x
    lowCount := 0
1214
18x
    totalCount := 0
1215
18x

1216
18x
    if high, ok := priorityDistribution["high"].(int); ok {
1217
18x
        highCount = high
1218
18x
        totalCount += high
1219
18x
    }
1220
18x
    if medium, ok := priorityDistribution["medium"].(int); ok {
1221
18x
        mediumCount = medium
1222
18x
        totalCount += medium
1223
18x
    }
1224
18x
    if low, ok := priorityDistribution["low"].(int); ok {
1225
18x
        lowCount = low
1226
18x
        totalCount += low
1227
18x
    }
1228

1229
18x
    return &api.PriorityInsights{
1230
18x
        TotalQuestionsInQueue:   &totalCount,
1231
18x
        HighPriorityQuestions:   &highCount,
1232
18x
        MediumPriorityQuestions: &mediumCount,
1233
18x
        LowPriorityQuestions:    &lowCount,
1234
18x
    }, nil
1235
}
1236

1237
18x
func (h *QuizHandler) getGenerationFocusForUser(ctx context.Context, userID int) (*api.GenerationFocus, error) {
1238
18x
    // Get user's AI configuration
1239
18x
    user, err := h.userService.GetUserByID(ctx, userID)
1240
18x
    if err != nil {
1241
        return nil, err
1242
    }
1243

1244
    // Get current generation model
1245
18x
    model := "default"
1246
18x
    if user.AIModel.Valid && user.AIModel.String != "" {
1247
17x
        model = user.AIModel.String
1248
17x
    }
1249

1250
    // Get last generation time (simplified - could be enhanced with actual generation logs)
1251
18x
    lastGenerationTime := time.Now().Add(-time.Hour) // Placeholder
1252
18x

1253
18x
    // Get generation rate (simplified - could be enhanced with actual metrics)
1254
18x
    generationRate := float32(2.5) // Placeholder: average questions per minute
1255
18x

1256
18x
    return &api.GenerationFocus{
1257
18x
        CurrentGenerationModel: &model,
1258
18x
        LastGenerationTime:     formatTimePtr(lastGenerationTime),
1259
18x
        GenerationRate:         &generationRate,
1260
18x
    }, nil
1261
}
1262

1263
18x
func (h *QuizHandler) getHighPriorityTopicsForUser(ctx context.Context, userID int) ([]string, error) {
1264
18x
    // Get high priority topics from learning service
1265
18x
    topics, err := h.learningService.GetHighPriorityTopics(ctx, userID)
1266
18x
    if err != nil {
1267
        return nil, err
1268
    }
1269
18x
    return topics, nil
1270
}
1271

1272
18x
func (h *QuizHandler) getGapAnalysisForUser(ctx context.Context, userID int) (map[string]interface{}, error) {
1273
18x
    // Get gap analysis from learning service
1274
18x
    gapAnalysis, err := h.learningService.GetGapAnalysis(ctx, userID)
1275
18x
    if err != nil {
1276
        return nil, err
1277
    }
1278
18x
    return gapAnalysis, nil
1279
}
1280

1281
18x
func (h *QuizHandler) getPriorityDistributionForUser(ctx context.Context, userID int) (map[string]int, error) {
1282
18x
    // Get priority distribution from learning service
1283
18x
    distribution, err := h.learningService.GetPriorityDistribution(ctx, userID)
1284
18x
    if err != nil {
1285
        return nil, err
1286
    }
1287
18x
    return distribution, nil
1288
}
1289

1290
26x
func convertLearningPreferencesToAPI(prefs *models.UserLearningPreferences) *api.UserLearningPreferences {
1291
26x
    out := &api.UserLearningPreferences{
1292
26x
        FocusOnWeakAreas:     prefs.FocusOnWeakAreas,
1293
26x
        FreshQuestionRatio:   float32(prefs.FreshQuestionRatio),
1294
26x
        KnownQuestionPenalty: float32(prefs.KnownQuestionPenalty),
1295
26x
        ReviewIntervalDays:   prefs.ReviewIntervalDays,
1296
26x
        WeakAreaBoost:        float32(prefs.WeakAreaBoost),
1297
26x
        DailyReminderEnabled: prefs.DailyReminderEnabled,
1298
26x
    }
1299
26x
    if prefs.TTSVoice != "" {
1300
1x
        v := prefs.TTSVoice
1301
1x
        out.TtsVoice = &v
1302
1x
    }
1303
26x
    if prefs.DailyGoal > 0 {
1304
22x
        dg := prefs.DailyGoal
1305
22x
        out.DailyGoal = &dg
1306
22x
    }
1307
26x
    return out
1308
}
1309


			
quizapp internal handlers worker_admin_handler.go
31.4%
Statements
11/35
1
package handlers
2

3
import (
4
    "fmt"
5
    "net/http"
6
    "sort"
7
    "strings"
8
    "time"
9

10
    "quizapp/internal/observability"
11

12
    "github.com/gin-gonic/gin"
13
)
14

15
// RouteInfo represents information about a single route
16
type RouteInfo struct {
17
    Method      string `json:"method"`
18
    Path        string `json:"path"`
19
    HandlerName string `json:"handler_name"`
20
}
21

22
// RouteListingHandler generates automatic route listings
23
type RouteListingHandler struct {
24
    serviceName string
25
    routes      []RouteInfo
26
}
27

28
// NewRouteListingHandler creates a new route listing handler
29
30x
func NewRouteListingHandler(serviceName string) *RouteListingHandler {
30
30x
    return &RouteListingHandler{
31
30x
        serviceName: serviceName,
32
30x
        routes:      []RouteInfo{},
33
30x
    }
34
30x
}
35

36
// CollectRoutes extracts all routes from a Gin engine
37
28x
func (h *RouteListingHandler) CollectRoutes(engine *gin.Engine) {
38
28x
    h.routes = []RouteInfo{}
39
28x

40
28x
    // Get all routes from the Gin engine
41
28x
    routes := engine.Routes()
42
28x

43
28x
    for _, route := range routes {
44
2092x
        // Skip internal Gin routes
45
2092x
        if strings.HasPrefix(route.Path, "/debug/") {
46
            continue
47
        }
48

49
2092x
        h.routes = append(h.routes, RouteInfo{
50
2092x
            Method:      route.Method,
51
2092x
            Path:        route.Path,
52
2092x
            HandlerName: route.Handler,
53
2092x
        })
54
    }
55

56
    // Sort routes by path for better organization
57
28x
    sort.Slice(h.routes, func(i, j int) bool {
58
14138x
        return h.routes[i].Path < h.routes[j].Path
59
14138x
    })
60
}
61

62
// GetRouteListingPage shows all available routes as HTML
63
func (h *RouteListingHandler) GetRouteListingPage(c *gin.Context) {
64
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_route_listing_page")
65
    defer observability.FinishSpan(span, nil)
66
    html := h.generateHTML()
67
    // Add no-cache headers
68
    c.Header("Content-Type", "text/html; charset=utf-8")
69
    c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
70
    c.Header("Pragma", "no-cache")
71
    c.Header("Expires", "0")
72
    c.String(http.StatusOK, html)
73
}
74

75
// GetRouteListingJSON returns the route listing as JSON
76
5x
func (h *RouteListingHandler) GetRouteListingJSON(c *gin.Context) {
77
5x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_route_listing_json")
78
5x
    defer observability.FinishSpan(span, nil)
79
5x
    c.JSON(http.StatusOK, h.routes)
80
5x
}
81

82
// generateHTML creates an HTML page listing all routes
83
func (h *RouteListingHandler) generateHTML() string {
84
    var html strings.Builder
85

86
    html.WriteString(`<!DOCTYPE html>
87
<html lang="en">
88
<head>
89
    <meta charset="UTF-8">
90
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
91
    <title>` + h.serviceName + ` - Available Routes</title>
92
    <style>
93
        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; padding: 20px; background-color: #f8f9fa; color: #212529; }
94
        .container { max-width: 1200px; margin: auto; background: #fff; padding: 30px; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.05); }
95
        h1 { color: #0056b3; border-bottom: 2px solid #dee2e6; padding-bottom: 10px; margin-bottom: 30px; }
96
        .service-info { background: #e7f3ff; padding: 15px; border-radius: 5px; margin-bottom: 30px; }
97
        .route-table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
98
        .route-table th, .route-table td { padding: 12px; text-align: left; border-bottom: 1px solid #dee2e6; }
99
        .route-table th { background-color: #f8f9fa; font-weight: 600; color: #495057; }
100
        .route-table tr:nth-child(even) { background-color: #f8f9fa; }
101
        .route-table tr:hover { background-color: #e9ecef; }
102
        .method { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; min-width: 60px; text-align: center; }
103
        .method-get { background-color: #d4edda; color: #155724; }
104
        .method-post { background-color: #cce5ff; color: #004085; }
105
        .method-put { background-color: #fff3cd; color: #856404; }
106
        .method-delete { background-color: #f8d7da; color: #721c24; }
107
        .method-patch { background-color: #e2e3e5; color: #383d41; }
108
        .path { font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; font-size: 14px; color: #6f42c1; }
109
        .clickable-path { cursor: pointer; text-decoration: underline; }
110
        .clickable-path:hover { background-color: #f8f9fa; }
111
        .footer { margin-top: 30px; text-align: center; color: #6c757d; font-size: 14px; }
112
        .stats { display: flex; gap: 20px; margin-bottom: 20px; }
113
        .stat-box { background: #ffffff; border: 1px solid #dee2e6; padding: 15px; border-radius: 5px; text-align: center; flex: 1; }
114
        .stat-number { font-size: 24px; font-weight: bold; color: #0056b3; }
115
        .stat-label { color: #6c757d; font-size: 14px; }
116
    </style>
117
</head>
118
<body>
119
    <div class="container">
120
        <h1>` + h.serviceName + ` Service - Available Routes</h1>
121

122
        <div class="service-info">
123
            <strong>Service:</strong> ` + h.serviceName + `<br>
124
            <strong>Generated:</strong> ` + time.Now().Format("2006-01-02 15:04:05") + `<br>
125
            <strong>Total Routes:</strong> ` + fmt.Sprintf("%d", len(h.routes)) + `
126
        </div>
127

128
        <div class="stats">
129
            <div class="stat-box">
130
                <div class="stat-number">` + fmt.Sprintf("%d", len(h.routes)) + `</div>
131
                <div class="stat-label">Total Routes</div>
132
            </div>
133
            <div class="stat-box">
134
                <div class="stat-number">` + fmt.Sprintf("%d", h.countMethods("GET")) + `</div>
135
                <div class="stat-label">GET Routes</div>
136
            </div>
137
            <div class="stat-box">
138
                <div class="stat-number">` + fmt.Sprintf("%d", h.countMethods("POST")) + `</div>
139
                <div class="stat-label">POST Routes</div>
140
            </div>
141
        </div>
142

143
        <table class="route-table">
144
            <thead>
145
                <tr>
146
                    <th>Method</th>
147
                    <th>Path</th>
148
                    <th>Handler</th>
149
                </tr>
150
            </thead>
151
            <tbody>`)
152

153
    for _, route := range h.routes {
154
        methodClass := "method-" + strings.ToLower(route.Method)
155
        pathClass := "path"
156

157
        // Make paths clickable for GET routes
158
        if route.Method == "GET" {
159
            pathClass += " clickable-path"
160
        }
161

162
        html.WriteString(fmt.Sprintf(`
163
                <tr>
164
                    <td><span class="method %s">%s</span></td>
165
                    <td><span class="%s" onclick="navigateToRoute('%s', '%s')">%s</span></td>
166
                    <td>%s</td>
167
                </tr>`,
168
            methodClass, route.Method,
169
            pathClass, route.Method, route.Path, route.Path,
170
            route.HandlerName,
171
        ))
172
    }
173

174
    html.WriteString(`
175
            </tbody>
176
        </table>
177

178
        <div class="footer">
179
            <p>Click on any GET route path to navigate to it | <a href="/?json=true">View as JSON</a></p>
180
        </div>
181
    </div>
182

183
    <script>
184
        function navigateToRoute(method, path) {
185
            if (method === 'GET') {
186
                window.location.href = path;
187
            } else {
188
                alert('Only GET routes can be navigated to directly. Use API client for ' + method + ' requests.');
189
            }
190
        }
191
    </script>
192
</body>
193
</html>`)
194

195
    return html.String()
196
}
197

198
// countMethods counts routes by HTTP method
199
func (h *RouteListingHandler) countMethods(method string) int {
200
    count := 0
201
    for _, route := range h.routes {
202
        if route.Method == method {
203
            count++
204
        }
205
    }
206
    return count
207
}
208


			
quizapp internal handlers worker_admin_handler.go
96.2%
Statements
275/286
1
package handlers
2

3
import (
4
    "encoding/json"
5
    "net/http"
6
    "os"
7
    "strings"
8
    "time"
9

10
    "github.com/gin-contrib/cors"
11
    "github.com/gin-contrib/secure"
12
    "github.com/gin-contrib/sessions"
13
    "github.com/gin-contrib/sessions/cookie"
14
    "github.com/gin-gonic/gin"
15
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
16

17
    "quizapp/internal/config"
18
    "quizapp/internal/middleware"
19
    "quizapp/internal/observability"
20
    "quizapp/internal/services"
21
    "quizapp/internal/version"
22
)
23

24
// IMPORTANT: When adding new API endpoints, make sure to:
25
// 1. Add them to swagger.yaml with proper documentation
26
// 2. Run `task generate-api-types` to regenerate types
27
// 3. Update any relevant tests
28
// 4. Consider if the endpoint should be public or admin-only
29

30
// NewRouter creates a new router factory with all the necessary middleware and routes
31
func NewRouter(
32
    cfg *config.Config,
33
    userService services.UserServiceInterface,
34
    questionService services.QuestionServiceInterface,
35
    learningService services.LearningServiceInterface,
36
    aiService services.AIServiceInterface,
37
    workerService services.WorkerServiceInterface,
38
    dailyQuestionService services.DailyQuestionServiceInterface,
39
    storyService services.StoryServiceInterface,
40
    conversationService services.ConversationServiceInterface,
41
    oauthService *services.OAuthService,
42
    generationHintService services.GenerationHintServiceInterface,
43
    translationService services.TranslationServiceInterface,
44
    snippetsService services.SnippetsServiceInterface,
45
    usageStatsService services.UsageStatsServiceInterface,
46
    wordOfTheDayService services.WordOfTheDayServiceInterface,
47
    authAPIKeyService services.AuthAPIKeyServiceInterface,
48
    translationPracticeService services.TranslationPracticeServiceInterface,
49
    logger *observability.Logger,
50
14x
) *gin.Engine {
51
14x
    // Setup Gin router
52
14x
    router := gin.New()
53
14x
    router.Use(gin.Recovery())
54
14x

55
14x
    // Add HTTP request logging middleware using our observability logger
56
14x
    router.Use(func(c *gin.Context) {
57
550x
        start := time.Now()
58
550x

59
550x
        // Process request
60
550x
        c.Next()
61
550x

62
550x
        // Log request details using our observability logger
63
550x
        latency := time.Since(start)
64
550x
        statusCode := c.Writer.Status()
65
550x
        clientIP := c.ClientIP()
66
550x
        method := c.Request.Method
67
550x
        path := c.Request.URL.Path
68
550x

69
550x
        // Create structured log entry
70
550x
        fields := map[string]interface{}{
71
550x
            "http.method":      method,
72
550x
            "http.path":        path,
73
550x
            "http.status_code": statusCode,
74
550x
            "http.latency_ms":  latency.Milliseconds(),
75
550x
            "http.client_ip":   clientIP,
76
550x
            "http.user_agent":  c.Request.UserAgent(),
77
550x
        }
78
550x

79
550x
        // Add error message if present
80
550x
        if len(c.Errors) > 0 {
81
            fields["http.error"] = c.Errors.String()
82
        }
83

84
        // For failed requests (4xx and 5xx), capture response body for debugging
85
550x
        if statusCode >= 400 {
86
110x
            // Get response body for error requests
87
110x
            if c.Writer.Size() > 0 {
88
110x
                // Try to capture response body for debugging
89
110x
                // Note: This is a best effort since the response may have already been written
90
110x
                fields["http.response_size"] = c.Writer.Size()
91
110x
            }
92

93
            // Add more context for 5xx errors
94
110x
            if statusCode >= 500 {
95
7x
                fields["http.error_type"] = "server_error"
96
7x
                // Log additional context that might help debugging
97
7x
                if c.Request.Body != nil {
98
                    fields["http.request_has_body"] = true
99
                }
100
103x
            } else {
101
103x
                fields["http.error_type"] = "client_error"
102
103x
            }
103
        }
104

105
        // Log using our observability logger (goes to both stdout and OTLP)
106
        // Use appropriate log level based on status code
107
550x
        if statusCode >= 500 {
108
7x
            logger.Error(c.Request.Context(), "HTTP request failed", nil, fields)
109
7x
        } else if statusCode >= 400 {
110
            logger.Warn(c.Request.Context(), "HTTP request warning", fields)
111
103x
        } else {
112
440x
            logger.Info(c.Request.Context(), "HTTP request", fields)
113
440x
        }
114
    })
115

116
    // Health check endpoint (defined before any middleware)
117
14x
    router.GET("/health", func(c *gin.Context) {
118
1x
        c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "backend"})
119
1x
    })
120

121
    // Add OpenTelemetry middleware for HTTP tracing and context propagation with automatic error attributes
122
14x
    router.Use(observability.GinMiddlewareWithErrorHandling("quiz-backend"))
123
14x

124
14x
    // Add response validation middleware for API endpoints
125
14x
    router.Use(middleware.ResponseValidationMiddleware(logger))
126
14x

127
14x
    // Swagger documentation (defined before middleware)
128
14x
    router.StaticFile("/swagger.yaml", "./swagger.yaml")
129
14x
    router.StaticFile("/swaggerz", "./swaggerz.html")
130
14x

131
14x
    // Disable automatic redirection for trailing slashes, which is better for APIs
132
14x
    router.RedirectTrailingSlash = false
133
14x

134
14x
    // Setup CORS middleware
135
14x
    corsConfig := cors.DefaultConfig()
136
14x
    corsConfig.AllowOrigins = cfg.Server.CORSOrigins
137
14x
    corsConfig.AllowCredentials = true
138
14x
    corsConfig.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "X-Requested-With"}
139
14x
    corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
140
14x
    router.Use(cors.New(corsConfig))
141
14x

142
14x
    // Setup session middleware
143
14x
    store := cookie.NewStore([]byte(cfg.Server.SessionSecret))
144
14x
    // Configure session options for security
145
14x
    sessionOpts := sessions.Options{
146
14x
        Path:     config.SessionPath,
147
14x
        MaxAge:   int(config.SessionMaxAge.Seconds()),
148
14x
        HttpOnly: config.SessionHTTPOnly,
149
14x
        Secure:   config.SessionSecure, // Set to true in production with HTTPS
150
14x
    }
151
14x
    if cfg.Server.Debug {
152
14x
        sessionOpts.SameSite = http.SameSiteDefaultMode
153
14x
    } else {
154
        sessionOpts.SameSite = http.SameSiteLaxMode
155
        sessionOpts.Secure = true
156
    }
157
14x
    store.Options(sessionOpts)
158
14x
    router.Use(sessions.Sessions(config.SessionName, store))
159
14x

160
14x
    // Setup Gin mode
161
14x
    gin.SetMode(gin.ReleaseMode)
162
14x
    if cfg.Server.Debug {
163
14x
        gin.SetMode(gin.DebugMode)
164
14x
    }
165

166
    // Security middleware
167
14x
    secureConfig := secure.DefaultConfig()
168
14x
    secureConfig.SSLRedirect = false
169
14x
    secureConfig.ContentSecurityPolicy = config.DefaultCSP
170
14x
    router.Use(secure.New(secureConfig))
171
14x

172
14x
    // Serve all static assets (JS, fonts, CSS, etc.) from /backend/*filepath
173
14x
    // Note: Static assets are now served from the frontend build
174
14x

175
14x
    // Initialize handlers
176
14x
    authHandler := NewAuthHandler(userService, oauthService, cfg, logger)
177
14x
    authAPIKeyHandler := NewAuthAPIKeyHandler(authAPIKeyService, logger)
178
14x
    emailService := services.CreateEmailService(cfg, logger)
179
14x
    settingsHandler := NewSettingsHandler(userService, storyService, conversationService, translationPracticeService, aiService, learningService, emailService, usageStatsService, cfg, logger)
180
14x
    quizHandler := NewQuizHandler(userService, questionService, aiService, learningService, workerService, generationHintService, usageStatsService, cfg, logger)
181
14x
    dailyQuestionHandler := NewDailyQuestionHandler(userService, dailyQuestionService, cfg, logger)
182
14x
    storyHandler := NewStoryHandler(storyService, userService, aiService, cfg, logger)
183
14x
    aiConversationHandler := NewAIConversationHandler(conversationService, cfg, logger)
184
14x
    translationHandler := NewTranslationHandler(translationService, cfg, logger)
185
14x
    translationPracticeHandler := NewTranslationPracticeHandler(translationPracticeService, aiService, userService, cfg, logger)
186
14x
    snippetsHandler := NewSnippetsHandler(snippetsService, cfg, logger)
187
14x
    wordOfTheDayHandler := NewWordOfTheDayHandler(userService, wordOfTheDayService, cfg, logger)
188
14x
    adminHandler := NewAdminHandlerWithLogger(userService, questionService, aiService, cfg, learningService, workerService, logger, usageStatsService)
189
14x
    // Inject story service into admin handler via exported field
190
14x
    adminHandler.storyService = storyService
191
14x
    userAdminHandler := NewUserAdminHandler(userService, cfg, logger)
192
14x
    verbConjugationHandler := NewVerbConjugationHandler(logger)
193
14x
    feedbackService := services.NewFeedbackService(userService.GetDB(), logger)
194
14x

195
14x
    // Initialize Linear service if enabled
196
14x
    var linearService *services.LinearService
197
14x
    if cfg.Linear.Enabled {
198
14x
        linearService = services.NewLinearService(cfg, logger)
199
14x
    }
200

201
14x
    feedbackHandler := NewFeedbackHandler(feedbackService, linearService, userService, cfg, logger)
202
14x

203
14x
    // V1 routes (matching swagger spec)
204
14x
    v1 := router.Group("/v1")
205
14x
    {
206
14x
        // Version aggregation endpoint (no auth)
207
14x
        v1.GET("/version", func(c *gin.Context) {
208
2x
            backendVersion := gin.H{
209
2x
                "service":   "backend",
210
2x
                "version":   version.Version,
211
2x
                "commit":    version.Commit,
212
2x
                "buildTime": version.BuildTime,
213
2x
            }
214
2x
            workerInternalURL := os.Getenv("WORKER_INTERNAL_URL")
215
2x
            if workerInternalURL == "" {
216
2x
                workerInternalURL = cfg.Server.WorkerInternalURL // fallback
217
2x
            }
218
            // Use instrumented HTTP client for tracing
219
2x
            client := &http.Client{
220
2x
                Transport: otelhttp.NewTransport(http.DefaultTransport),
221
2x
            }
222
2x
            req, err := http.NewRequest("GET", workerInternalURL+"/v1/version", nil)
223
2x
            var workerResp *http.Response
224
2x
            if err == nil {
225
2x
                req = req.WithContext(c.Request.Context())
226
2x
                workerResp, err = client.Do(req)
227
2x
            }
228
2x
            var workerVersion interface{}
229
2x
            if err == nil && workerResp.StatusCode == http.StatusOK {
230
                defer func() { _ = workerResp.Body.Close() }()
231
                if err := json.NewDecoder(workerResp.Body).Decode(&workerVersion); err != nil {
232
                    workerVersion = gin.H{"error": "Failed to decode worker version"}
233
                }
234
2x
            } else {
235
2x
                workerVersion = gin.H{"error": "Worker unavailable"}
236
2x
            }
237
2x
            c.JSON(http.StatusOK, gin.H{
238
2x
                "backend": backendVersion,
239
2x
                "worker":  workerVersion,
240
2x
            })
241
        })
242
14x
        auth := v1.Group("/auth")
243
14x
        {
244
14x
            auth.POST("/login", middleware.RequestValidationMiddleware(logger), authHandler.Login)
245
14x
            auth.POST("/logout", authHandler.Logout)
246
14x
            auth.GET("/status", authHandler.Status)
247
14x
            auth.GET("/check", middleware.RequireAuth(), authHandler.Check)
248
14x
            auth.POST("/signup", middleware.RequestValidationMiddleware(logger), authHandler.Signup)
249
14x
            auth.GET("/signup/status", authHandler.SignupStatus)
250
14x
            auth.GET("/google/login", authHandler.GoogleLogin)
251
14x
            auth.GET("/google/callback", authHandler.GoogleCallback)
252
14x
        }
253

254
        // API Keys routes (for programmatic API access)
255
14x
        apiKeys := v1.Group("/api-keys")
256
14x
        apiKeys.Use(middleware.RequireAuth()) // Keep session-only auth for managing API keys
257
14x
        {
258
14x
            apiKeys.POST("", middleware.RequestValidationMiddleware(logger), authAPIKeyHandler.CreateAPIKey)
259
14x
            apiKeys.GET("", authAPIKeyHandler.ListAPIKeys)
260
14x
            apiKeys.DELETE("/:id", authAPIKeyHandler.DeleteAPIKey)
261
14x
        }
262

263
        // API Key test endpoints using API key auth (no cookies)
264
14x
        apiKeysTest := v1.Group("/api-keys")
265
14x
        apiKeysTest.Use(middleware.RequireAuthWithAPIKey(authAPIKeyService, userService))
266
14x
        {
267
14x
            apiKeysTest.GET("/test-read", authAPIKeyHandler.TestRead)
268
14x
            apiKeysTest.POST("/test-write", authAPIKeyHandler.TestWrite)
269
14x
        }
270

271
        // Translation routes
272
14x
        v1.POST("/translate", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), translationHandler.TranslateText)
273
14x

274
14x
        // Translation practice routes
275
14x
        translationPracticeHandler.RegisterRoutes(router)
276
14x

277
14x
        // Feedback routes
278
14x
        v1.POST("/feedback", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), feedbackHandler.SubmitFeedback)
279
14x

280
14x
        // Snippets routes
281
14x
        v1.POST("/snippets", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), snippetsHandler.CreateSnippet)
282
14x
        v1.GET("/snippets", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.GetSnippets)
283
14x
        v1.DELETE("/snippets", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.DeleteAllSnippets)
284
14x
        v1.GET("/snippets/search", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.SearchSnippets)
285
14x
        v1.GET("/snippets/by-question/:question_id", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.GetSnippetsByQuestion)
286
14x
        v1.GET("/snippets/by-section/:section_id", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.GetSnippetsBySection)
287
14x
        v1.GET("/snippets/by-story/:story_id", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.GetSnippetsByStory)
288
14x
        v1.GET("/snippets/:id", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.GetSnippet)
289
14x
        v1.PUT("/snippets/:id", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), snippetsHandler.UpdateSnippet)
290
14x
        v1.DELETE("/snippets/:id", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.DeleteSnippet)
291
14x

292
14x
        quiz := v1.Group("/quiz")
293
14x
        quiz.Use(middleware.RequireAuthWithAPIKey(authAPIKeyService, userService))
294
14x
        quiz.Use(middleware.RequestValidationMiddleware(logger))
295
14x
        {
296
14x
            quiz.GET("/question", quizHandler.GetQuestion)
297
14x
            quiz.GET("/question/:id", quizHandler.GetQuestion)
298
14x
            quiz.POST("/question/:id/report", quizHandler.ReportQuestion)
299
14x
            quiz.POST("/question/:id/mark-known", quizHandler.MarkQuestionAsKnown)
300
14x
            quiz.POST("/answer", quizHandler.SubmitAnswer)
301
14x
            quiz.GET("/progress", quizHandler.GetProgress)
302
14x
            quiz.GET("/ai-token-usage", quizHandler.GetAITokenUsage)
303
14x
            quiz.GET("/ai-token-usage/daily", quizHandler.GetAITokenUsageDaily)
304
14x
            quiz.GET("/ai-token-usage/hourly", quizHandler.GetAITokenUsageHourly)
305
14x
            quiz.GET("/worker-status", quizHandler.GetWorkerStatus)
306
14x
            quiz.POST("/chat/stream", quizHandler.ChatStream)
307
14x
        }
308
14x
        daily := v1.Group("/daily")
309
14x
        daily.Use(middleware.RequireAuthWithAPIKey(authAPIKeyService, userService))
310
14x
        daily.Use(middleware.RequestValidationMiddleware(logger))
311
14x
        {
312
14x
            daily.GET("/questions/:date", dailyQuestionHandler.GetDailyQuestions)
313
14x
            daily.POST("/questions/:date/complete/:questionId", dailyQuestionHandler.MarkQuestionCompleted)
314
14x
            daily.DELETE("/questions/:date/complete/:questionId", dailyQuestionHandler.ResetQuestionCompleted)
315
14x
            daily.POST("/questions/:date/answer/:questionId", dailyQuestionHandler.SubmitDailyQuestionAnswer)
316
14x
            daily.GET("/history/:questionId", dailyQuestionHandler.GetQuestionHistory)
317
14x
            daily.GET("/dates", dailyQuestionHandler.GetAvailableDates)
318
14x
            daily.GET("/progress/:date", dailyQuestionHandler.GetDailyProgress)
319
14x
            // Note: Assignment is handled automatically by the worker
320
14x
        }
321

322
14x
        wordOfDay := v1.Group("/word-of-day")
323
14x
        {
324
14x
            // Protected endpoints requiring authentication (API key or session)
325
14x
            wordOfDay.GET("", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), wordOfTheDayHandler.GetWordOfTheDayToday)
326
14x
            wordOfDay.GET("/history", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), wordOfTheDayHandler.GetWordOfTheDayHistory)
327
14x
            // Embed endpoint supports optional date query parameter and requires API key or session auth
328
14x
            wordOfDay.GET("/embed", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), wordOfTheDayHandler.GetWordOfTheDayEmbed)
329
14x
            wordOfDay.GET("/:date/embed", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), wordOfTheDayHandler.GetWordOfTheDayEmbed)
330
14x
            wordOfDay.GET("/:date", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), wordOfTheDayHandler.GetWordOfTheDay)
331
14x
        }
332

333
14x
        story := v1.Group("/story")
334
14x
        story.Use(middleware.RequireAuthWithAPIKey(authAPIKeyService, userService))
335
14x
        story.Use(middleware.RequestValidationMiddleware(logger))
336
14x
        {
337
14x
            story.POST("", storyHandler.CreateStory)
338
14x
            story.GET("", storyHandler.GetUserStories)
339
14x
            story.GET("/current", storyHandler.GetCurrentStory)
340
14x
            story.GET("/:id", storyHandler.GetStory)
341
14x
            story.GET("/section/:id", storyHandler.GetSection)
342
14x
            story.POST("/:id/generate", storyHandler.GenerateNextSection)
343
14x
            story.POST("/:id/archive", storyHandler.ArchiveStory)
344
14x
            story.POST("/:id/complete", storyHandler.CompleteStory)
345
14x
            story.POST("/:id/set-current", storyHandler.SetCurrentStory)
346
14x
            story.POST("/:id/toggle-auto-generation", storyHandler.ToggleAutoGeneration)
347
14x
            story.DELETE("/:id", storyHandler.DeleteStory)
348
14x
            story.GET("/:id/export", storyHandler.ExportStory)
349
14x
        }
350
14x
        settings := v1.Group("/settings")
351
14x
        {
352
14x
            settings.GET("/ai-providers", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), settingsHandler.GetProviders)
353
14x
            settings.GET("/levels", settingsHandler.GetLevels)
354
14x
            settings.GET("/languages", settingsHandler.GetLanguages)
355
14x
            settings.POST("/test-ai", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), settingsHandler.TestAIConnection)
356
14x
            settings.POST("/test-email", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), settingsHandler.SendTestEmail)
357
14x
            settings.PUT("", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), settingsHandler.UpdateUserSettings)
358
14x
            settings.PUT("/word-of-day-email", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), settingsHandler.UpdateWordOfDayEmailPreference)
359
14x
            // User data management endpoints
360
14x
            settings.POST("/clear-stories", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), settingsHandler.ClearAllStories)
361
14x
            settings.POST("/clear-ai-chats", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), settingsHandler.ClearAllAIChats)
362
14x
            settings.POST("/clear-translation-practice-history", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), settingsHandler.ClearAllTranslationPracticeHistory)
363
14x
            settings.POST("/reset-account", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), settingsHandler.ResetAccount)
364
14x
            settings.GET("/api-key/:provider", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), settingsHandler.CheckAPIKeyAvailability)
365
14x
        }
366

367
        // Verb conjugation endpoints
368
14x
        verbConjugations := v1.Group("/verb-conjugations")
369
14x
        verbConjugations.Use(middleware.RequireAuthWithAPIKey(authAPIKeyService, userService))
370
14x
        {
371
14x
            verbConjugations.GET("/info", verbConjugationHandler.GetVerbConjugationInfo)
372
14x
            verbConjugations.GET("/languages", verbConjugationHandler.GetAvailableLanguages)
373
14x
            verbConjugations.GET("/:language", verbConjugationHandler.GetVerbConjugations)
374
14x
            verbConjugations.GET("/:language/:verb", verbConjugationHandler.GetVerbConjugation)
375
14x
        }
376

377
        // AI conversation endpoints
378
14x
        ai := v1.Group("/ai")
379
14x
        ai.Use(middleware.RequireAuthWithAPIKey(authAPIKeyService, userService))
380
14x
        ai.Use(middleware.RequestValidationMiddleware(logger))
381
14x
        {
382
14x
            ai.GET("/conversations", aiConversationHandler.GetConversations)
383
14x
            ai.POST("/conversations", aiConversationHandler.CreateConversation)
384
14x
            ai.GET("/conversations/:id", aiConversationHandler.GetConversation)
385
14x
            ai.PUT("/conversations/:id", aiConversationHandler.UpdateConversation)
386
14x
            ai.DELETE("/conversations/:id", aiConversationHandler.DeleteConversation)
387
14x
            ai.POST("/conversations/:conversationId/messages", aiConversationHandler.AddMessage)
388
14x
            ai.PUT("/conversations/bookmark", aiConversationHandler.ToggleMessageBookmark)
389
14x
            ai.GET("/search", aiConversationHandler.SearchConversations)
390
14x
            ai.GET("/bookmarks", aiConversationHandler.GetBookmarkedMessages)
391
14x
        }
392
14x
        preferences := v1.Group("/preferences")
393
14x
        preferences.Use(middleware.RequireAuthWithAPIKey(authAPIKeyService, userService))
394
14x
        preferences.Use(middleware.RequestValidationMiddleware(logger))
395
14x
        {
396
14x
            preferences.GET("/learning", settingsHandler.GetLearningPreferences)
397
14x
            preferences.PUT("/learning", settingsHandler.UpdateLearningPreferences)
398
14x
        }
399

400
        // User management endpoints (non-admin)
401
14x
        userz := v1.Group("/userz")
402
14x
        {
403
14x
            userz.PUT("/profile", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), userAdminHandler.UpdateCurrentUserProfile)
404
14x
        }
405

406
        // Admin endpoints
407
14x
        admin := v1.Group("/admin")
408
14x
        admin.Use(middleware.RequireAdmin(userService))
409
14x
        admin.Use(middleware.RequestValidationMiddleware(logger))
410
14x
        {
411
14x
            // Backend admin endpoints
412
14x
            backend := admin.Group("/backend")
413
14x
            {
414
14x
                // Backend admin page
415
14x
                backend.GET("", adminHandler.GetBackendAdminPage)
416
14x
                // Feedback management (admin only)
417
14x
                backend.GET("/feedback", feedbackHandler.ListFeedback)
418
14x
                backend.GET("/feedback/:id", feedbackHandler.GetFeedback)
419
14x
                backend.PATCH("/feedback/:id", feedbackHandler.UpdateFeedback)
420
14x
                backend.DELETE("/feedback/:id", feedbackHandler.DeleteFeedback)
421
14x
                backend.DELETE("/feedback", func(c *gin.Context) {
422
2x
                    // Check if it's a delete all request
423
2x
                    if c.Query("all") == "true" {
424
                        feedbackHandler.DeleteAllFeedback(c)
425
                    } else {
426
2x
                        feedbackHandler.DeleteFeedbackByStatus(c)
427
2x
                    }
428
                })
429
14x
                backend.POST("/feedback/:id/linear-issue", feedbackHandler.CreateLinearIssue)
430
14x
                // User management (admin only)
431
14x
                backend.GET("/userz", userAdminHandler.GetAllUsers)
432
14x
                backend.GET("/userz/paginated", userAdminHandler.GetUsersPaginated)
433
14x
                backend.POST("/userz", userAdminHandler.CreateUser)
434
14x
                backend.PUT("/userz/:id", userAdminHandler.UpdateUser)
435
14x
                backend.DELETE("/userz/:id", userAdminHandler.DeleteUser)
436
14x
                backend.POST("/userz/:id/reset-password", userAdminHandler.ResetUserPassword)
437
14x

438
14x
                // Role management endpoints
439
14x
                backend.GET("/roles", adminHandler.GetRoles)
440
14x
                backend.GET("/userz/:id/roles", adminHandler.GetUserRoles)
441
14x
                backend.POST("/userz/:id/roles", adminHandler.AssignRole)
442
14x
                backend.DELETE("/userz/:id/roles/:roleId", adminHandler.RemoveRole)
443
14x

444
14x
                // Admin dashboard data
445
14x
                backend.GET("/dashboard", adminHandler.GetBackendAdminData)
446
14x
                backend.GET("/ai-concurrency", adminHandler.GetAIConcurrencyStats)
447
14x

448
14x
                // Question management
449
14x
                backend.GET("/questions/:id", adminHandler.GetQuestion)
450
14x
                backend.GET("/questions/:id/users", adminHandler.GetUsersForQuestion)
451
14x
                backend.PUT("/questions/:id", adminHandler.UpdateQuestion)
452
14x
                backend.DELETE("/questions/:id", adminHandler.DeleteQuestion)
453
14x
                backend.POST("/questions/:id/assign-users", adminHandler.AssignUsersToQuestion)
454
14x
                backend.POST("/questions/:id/unassign-users", adminHandler.UnassignUsersFromQuestion)
455
14x
                backend.GET("/questions/paginated", adminHandler.GetQuestionsPaginated)
456
14x
                backend.GET("/questions", adminHandler.GetAllQuestions)
457
14x
                backend.GET("/reported-questions", adminHandler.GetReportedQuestionsPaginated)
458
14x
                backend.POST("/questions/:id/fix", adminHandler.MarkQuestionAsFixed)
459
14x
                backend.POST("/questions/:id/ai-fix", adminHandler.FixQuestionWithAI)
460
14x

461
14x
                // Data management
462
14x
                backend.POST("/clear-user-data", adminHandler.ClearUserData)
463
14x
                backend.POST("/clear-database", adminHandler.ClearDatabase)
464
14x
                backend.POST("/userz/:id/clear", adminHandler.ClearUserDataForUser)
465
14x

466
14x
                // Story explorer (admin)
467
14x
                backend.GET("/stories", adminHandler.GetStoriesPaginated)
468
14x
                backend.GET("/stories/:id", adminHandler.GetStoryAdmin)
469
14x
                backend.DELETE("/stories/:id", adminHandler.DeleteStoryAdmin)
470
14x
                backend.GET("/story-sections/:id", adminHandler.GetSectionAdmin)
471
14x

472
14x
                // Usage stats (admin)
473
14x
                backend.GET("/usage-stats", adminHandler.GetUsageStats)
474
14x
                backend.GET("/usage-stats/:service", adminHandler.GetUsageStatsByService)
475
            }
476

477
        }
478
    }
479

480
    // Config dump endpoint
481
14x
    router.GET("/configz", adminHandler.GetConfigz)
482
14x

483
14x
    // Serve frontend static files
484
14x
    router.Static("/assets", "./frontend/dist/assets")
485
14x
    router.StaticFile("/favicon.svg", "./frontend/dist/favicon.svg")
486
14x
    router.StaticFile("/fonts", "./frontend/dist/fonts")
487
14x

488
14x
    // Catch-all route for SPA - serve index.html for any route that doesn't match API routes
489
14x
    router.NoRoute(func(c *gin.Context) {
490
11x
        // Don't serve index.html for API routes
491
11x
        if strings.HasPrefix(c.Request.URL.Path, "/v1/") ||
492
11x
            strings.HasPrefix(c.Request.URL.Path, "/configz") ||
493
11x
            strings.HasPrefix(c.Request.URL.Path, "/swagger") ||
494
11x
            strings.HasPrefix(c.Request.URL.Path, "/backend/") {
495
11x
            c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
496
11x
            return
497
11x
        }
498

499
        // Serve the frontend's index.html for all other routes
500
        c.File("./frontend/dist/index.html")
501
    })
502

503
    // Automatic route listing at root path
504
14x
    routeListing := NewRouteListingHandler("Backend")
505
14x
    routeListing.CollectRoutes(router)
506
14x

507
14x
    // Root path shows all available routes
508
14x
    router.GET("/", func(c *gin.Context) {
509
1x
        if c.Query("json") == "true" {
510
1x
            routeListing.GetRouteListingJSON(c)
511
1x
        } else {
512
            routeListing.GetRouteListingPage(c)
513
        }
514
    })
515

516
14x
    return router
517
}
518


			
quizapp internal handlers worker_admin_handler.go
61.5%
Statements
16/26
1
package handlers
2

3
import (
4
    "quizapp/internal/middleware"
5

6
    "github.com/gin-contrib/sessions"
7
    "github.com/gin-gonic/gin"
8
)
9

10
// GetUserIDFromSession retrieves the current user ID from the session or context.
11
// Returns (0, false) if not authenticated or if the stored value is invalid.
12
202x
func GetUserIDFromSession(c *gin.Context) (int, bool) {
13
202x
    // First check if user ID is already in context (set by auth middleware)
14
202x
    if userIDVal, exists := c.Get(middleware.UserIDKey); exists {
15
167x
        if id, ok := userIDVal.(int); ok {
16
151x
            return id, true
17
151x
        }
18
        // Try to convert from uint (common in tests)
19
16x
        if idUint, ok := userIDVal.(uint); ok {
20
16x
            return int(idUint), true
21
16x
        }
22
        // If it's some other type in context, it's invalid
23
        return 0, false
24
    }
25

26
    // Fall back to session if not in context (maintain original behavior for sessions)
27
35x
    session := sessions.Default(c)
28
35x
    userID := session.Get(middleware.UserIDKey)
29
35x
    if userID == nil {
30
1x
        return 0, false
31
1x
    }
32
33x
    id, ok := userID.(int)
33
33x
    if !ok {
34
1x
        return 0, false
35
1x
    }
36
31x
    return id, true
37
}
38

39
// GetUsernameFromSession retrieves the current user username from the session or context.
40
// Returns (0, false) if not authenticated or if the stored value is invalid.
41
7x
func GetUsernameFromSession(c *gin.Context) (string, bool) {
42
7x
    // First check if user ID is already in context (set by auth middleware)
43
7x
    if usernameVal, exists := c.Get(middleware.UsernameKey); exists {
44
7x
        if username, ok := usernameVal.(string); ok {
45
7x
            return username, true
46
7x
        }
47
        return "", false
48
    }
49

50
    // Fall back to session if not in context (maintain original behavior for sessions)
51
    session := sessions.Default(c)
52
    username := session.Get(middleware.UsernameKey)
53
    if username == nil {
54
        return "", false
55
    }
56
    usernameStr, ok := username.(string)
57
    if !ok {
58
        return "", false
59
    }
60
    return usernameStr, true
61
}
62


			
quizapp internal handlers worker_admin_handler.go
69.5%
Statements
189/272
1
package handlers
2

3
import (
4
    "fmt"
5
    "net/http"
6

7
    "quizapp/internal/api"
8
    "quizapp/internal/config"
9
    "quizapp/internal/middleware"
10
    "quizapp/internal/models"
11
    "quizapp/internal/observability"
12
    "quizapp/internal/services"
13
    "quizapp/internal/services/mailer"
14
    contextutils "quizapp/internal/utils"
15

16
    "github.com/gin-contrib/sessions"
17
    "github.com/gin-gonic/gin"
18
    "go.opentelemetry.io/otel/attribute"
19
)
20

21
// SettingsHandler handles user settings related HTTP requests
22
type SettingsHandler struct {
23
    userService                services.UserServiceInterface
24
    storyService               services.StoryServiceInterface
25
    conversationService        services.ConversationServiceInterface
26
    translationPracticeService services.TranslationPracticeServiceInterface
27
    aiService                  services.AIServiceInterface
28
    learningService            services.LearningServiceInterface
29
    usageStatsSvc              services.UsageStatsServiceInterface
30
    emailService               mailer.Mailer
31
    cfg                        *config.Config
32
    logger                     *observability.Logger
33
}
34

35
// NewSettingsHandler creates a new SettingsHandler instance
36
29x
func NewSettingsHandler(userService services.UserServiceInterface, storyService services.StoryServiceInterface, conversationService services.ConversationServiceInterface, translationPracticeService services.TranslationPracticeServiceInterface, aiService services.AIServiceInterface, learningService services.LearningServiceInterface, emailService mailer.Mailer, usageStatsSvc services.UsageStatsServiceInterface, cfg *config.Config, logger *observability.Logger) *SettingsHandler {
37
29x
    return &SettingsHandler{
38
29x
        userService:                userService,
39
29x
        storyService:               storyService,
40
29x
        conversationService:        conversationService,
41
29x
        translationPracticeService: translationPracticeService,
42
29x
        aiService:                  aiService,
43
29x
        learningService:            learningService,
44
29x
        usageStatsSvc:              usageStatsSvc,
45
29x
        emailService:               emailService,
46
29x
        cfg:                        cfg,
47
29x
        logger:                     logger,
48
29x
    }
49
29x
}
50

51
// UpdateWordOfDayEmailPreference updates the user's word-of-day email preference
52
2x
func (h *SettingsHandler) UpdateWordOfDayEmailPreference(c *gin.Context) {
53
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "update_word_of_day_email_preference")
54
2x
    defer observability.FinishSpan(span, nil)
55
2x

56
2x
    session := sessions.Default(c)
57
2x
    userID, ok := session.Get(middleware.UserIDKey).(int)
58
2x
    if !ok {
59
        HandleAppError(c, contextutils.ErrUnauthorized)
60
        return
61
    }
62

63
2x
    var body struct {
64
2x
        Enabled bool `json:"enabled"`
65
2x
    }
66
2x
    if err := c.ShouldBindJSON(&body); err != nil {
67
        HandleAppError(c, contextutils.NewAppErrorWithCause(
68
            contextutils.ErrorCodeInvalidInput,
69
            contextutils.SeverityWarn,
70
            "Invalid request body",
71
            "",
72
            err,
73
        ))
74
        return
75
    }
76

77
2x
    if err := h.userService.UpdateWordOfDayEmailEnabled(ctx, userID, body.Enabled); err != nil {
78
        HandleAppError(c, contextutils.WrapError(err, "failed to update word of day email preference"))
79
        return
80
    }
81

82
2x
    c.JSON(http.StatusOK, gin.H{"success": true})
83
}
84

85
// UpdateUserSettings handles updating user settings
86
16x
func (h *SettingsHandler) UpdateUserSettings(c *gin.Context) {
87
16x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "update_user_settings")
88
16x
    defer observability.FinishSpan(span, nil)
89
16x
    session := sessions.Default(c)
90
16x
    userID, ok := session.Get(middleware.UserIDKey).(int)
91
16x
    if !ok {
92
        HandleAppError(c, contextutils.ErrUnauthorized)
93
        return
94
    }
95

96
16x
    var settings api.UserSettings
97
16x
    if err := c.ShouldBindJSON(&settings); err != nil {
98
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
99
1x
            contextutils.ErrorCodeInvalidInput,
100
1x
            contextutils.SeverityWarn,
101
1x
            "Invalid request body",
102
1x
            "",
103
1x
            err,
104
1x
        ))
105
1x
        return
106
1x
    }
107

108
    // Validate that at least one meaningful field is provided
109
    // Avoid relying on generated union/raw fields that may be non-nil for an empty JSON body
110
15x
    hasAnyField := settings.Language != nil ||
111
15x
        settings.Level != nil ||
112
15x
        settings.AiProvider != nil ||
113
15x
        settings.AiModel != nil ||
114
15x
        settings.ApiKey != nil ||
115
15x
        settings.AiEnabled != nil
116
15x

117
15x
    if !hasAnyField {
118
1x
        HandleAppError(c, contextutils.ErrInvalidInput)
119
1x
        return
120
1x
    }
121

122
    // Convert api.UserSettings to models.UserSettings
123
14x
    modelSettings := models.UserSettings{}
124
14x
    if settings.Language != nil {
125
14x
        modelSettings.Language = string(*settings.Language)
126
14x
        span.SetAttributes(attribute.String("settings.language", modelSettings.Language))
127
14x
    }
128
14x
    if settings.Level != nil {
129
13x
        modelSettings.Level = string(*settings.Level)
130
13x
        span.SetAttributes(attribute.String("settings.level", modelSettings.Level))
131
13x
    }
132
14x
    if settings.AiProvider != nil {
133
9x
        modelSettings.AIProvider = *settings.AiProvider
134
9x
        span.SetAttributes(attribute.String("settings.ai_provider", modelSettings.AIProvider))
135
9x
    }
136
14x
    if settings.AiModel != nil {
137
10x
        modelSettings.AIModel = *settings.AiModel
138
10x
        span.SetAttributes(attribute.String("settings.ai_model", modelSettings.AIModel))
139
10x
    }
140
14x
    if settings.ApiKey != nil {
141
10x
        modelSettings.AIAPIKey = *settings.ApiKey
142
10x
        span.SetAttributes(attribute.Bool("settings.api_key_provided", true))
143
10x
    }
144
14x
    if settings.AiEnabled != nil {
145
10x
        modelSettings.AIEnabled = *settings.AiEnabled
146
10x
        span.SetAttributes(attribute.Bool("settings.ai_enabled", modelSettings.AIEnabled))
147
10x
    }
148

149
    // Validate level if provided (including empty string)
150
14x
    if settings.Level != nil {
151
13x
        validLevels := h.cfg.GetAllLevels()
152
13x
        isValidLevel := false
153
13x
        for _, level := range validLevels {
154
85x
            if modelSettings.Level == level {
155
10x
                isValidLevel = true
156
10x
                break
157
            }
158
        }
159

160
13x
        if !isValidLevel {
161
3x
            HandleAppError(c, contextutils.ErrInvalidFormat)
162
3x
            return
163
3x
        }
164
    }
165

166
    // Validate language if provided (including empty string)
167
11x
    if settings.Language != nil {
168
11x
        validLanguages := h.cfg.GetLanguages()
169
11x
        isValidLanguage := false
170
11x
        for _, language := range validLanguages {
171
60x
            if modelSettings.Language == language {
172
10x
                isValidLanguage = true
173
10x
                break
174
            }
175
        }
176

177
11x
        if !isValidLanguage {
178
1x
            HandleAppError(c, contextutils.ErrInvalidFormat)
179
1x
            return
180
1x
        }
181
    }
182

183
10x
    if err := h.userService.UpdateUserSettings(c.Request.Context(), userID, &modelSettings); err != nil {
184
        // Check if the error is due to user not found
185
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
186
            HandleAppError(c, contextutils.ErrRecordNotFound)
187
            return
188
        }
189
        HandleAppError(c, contextutils.WrapError(err, "failed to update settings"))
190
        return
191
    }
192

193
10x
    c.JSON(http.StatusOK, api.SuccessResponse{Success: true})
194
}
195

196
// TestAIConnection tests the AI service connection with provided settings
197
2x
func (h *SettingsHandler) TestAIConnection(c *gin.Context) {
198
2x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "test_ai_connection")
199
2x
    defer observability.FinishSpan(span, nil)
200
2x
    session := sessions.Default(c)
201
2x
    userID, ok := session.Get(middleware.UserIDKey).(int)
202
2x
    if !ok {
203
        HandleAppError(c, contextutils.ErrUnauthorized)
204
        return
205
    }
206

207
2x
    var req api.TestAIRequest
208
2x
    if err := c.ShouldBindJSON(&req); err != nil {
209
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
210
1x
            contextutils.ErrorCodeInvalidInput,
211
1x
            contextutils.SeverityWarn,
212
1x
            "Invalid request format",
213
1x
            "",
214
1x
            err,
215
1x
        ))
216
1x
        return
217
1x
    }
218

219
    // Extract values from API request
220
1x
    provider := req.Provider
221
1x
    model := req.Model
222
1x
    apiKey := ""
223
1x
    if req.ApiKey != nil {
224
1x
        apiKey = *req.ApiKey
225
1x
    }
226

227
    // If API key is empty, try to use the saved one from the new user_api_keys table
228
1x
    if apiKey == "" {
229
        savedKey, err := h.userService.GetUserAPIKey(c.Request.Context(), userID, provider)
230
        if err != nil {
231
            HandleAppError(c, contextutils.WrapError(err, "failed to get saved API key"))
232
            return
233
        }
234
        apiKey = savedKey
235
    }
236

237
1x
    err := h.aiService.TestConnection(c.Request.Context(), provider, model, apiKey)
238
1x
    if err != nil {
239
1x
        c.JSON(http.StatusOK, gin.H{
240
1x
            "success": false,
241
1x
            "message": fmt.Sprintf("Model '%s': %s", model, err.Error()),
242
1x
        })
243
1x
        return
244
1x
    }
245

246
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "Connection successful"})
247
}
248

249
// GetProviders returns the available AI provider configurations
250
3x
func (h *SettingsHandler) GetProviders(c *gin.Context) {
251
3x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_providers")
252
3x
    defer observability.FinishSpan(span, nil)
253
3x

254
3x
    response := gin.H{
255
3x
        "providers": h.cfg.Providers,
256
3x
        "levels":    h.cfg.GetAllLevels(),
257
3x
        "languages": h.cfg.GetLanguages(),
258
3x
    }
259
3x
    c.JSON(http.StatusOK, response)
260
3x
}
261

262
// GetLevels returns the available levels and their descriptions.
263
25x
func (h *SettingsHandler) GetLevels(c *gin.Context) {
264
25x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_levels")
265
25x
    defer observability.FinishSpan(span, nil)
266
25x
    language := c.Query("language")
267
25x
    if language != "" {
268
22x
        levels := h.cfg.GetLevelsForLanguage(language)
269
22x
        descriptions := h.cfg.GetLevelDescriptionsForLanguage(language)
270
22x
        c.JSON(http.StatusOK, gin.H{
271
22x
            "levels":             levels,
272
22x
            "level_descriptions": descriptions,
273
22x
        })
274
22x
        return
275
22x
    }
276
3x
    c.JSON(http.StatusOK, gin.H{
277
3x
        "levels":             h.cfg.GetAllLevels(),
278
3x
        "level_descriptions": h.cfg.GetAllLevelDescriptions(),
279
3x
    })
280
}
281

282
// GetLanguages returns the available languages.
283
3x
func (h *SettingsHandler) GetLanguages(c *gin.Context) {
284
3x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_languages")
285
3x
    defer observability.FinishSpan(span, nil)
286
3x
    c.JSON(http.StatusOK, h.cfg.GetLanguageInfoList())
287
3x
}
288

289
// CheckAPIKeyAvailability checks if the user has a saved API key for the specified provider
290
2x
func (h *SettingsHandler) CheckAPIKeyAvailability(c *gin.Context) {
291
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "check_api_key_availability")
292
2x
    defer observability.FinishSpan(span, nil)
293
2x
    session := sessions.Default(c)
294
2x
    userID, ok := session.Get(middleware.UserIDKey).(int)
295
2x
    if !ok {
296
        HandleAppError(c, contextutils.ErrUnauthorized)
297
        return
298
    }
299

300
2x
    provider := c.Param("provider")
301
2x
    if provider == "" {
302
        HandleAppError(c, contextutils.ErrMissingRequired)
303
        return
304
    }
305

306
    // Check if user has a saved API key for this provider
307
2x
    hasAPIKey, err := h.userService.HasUserAPIKey(ctx, userID, provider)
308
2x
    if err != nil {
309
        h.logger.Error(ctx, "Failed to check API key availability", err, map[string]interface{}{
310
            "user_id":  userID,
311
            "provider": provider,
312
        })
313
        HandleAppError(c, contextutils.WrapError(err, "failed to check API key availability"))
314
        return
315
    }
316

317
2x
    c.JSON(http.StatusOK, gin.H{"has_api_key": hasAPIKey})
318
}
319

320
// GetLearningPreferences retrieves user learning preferences
321
5x
func (h *SettingsHandler) GetLearningPreferences(c *gin.Context) {
322
5x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_learning_preferences")
323
5x
    defer observability.FinishSpan(span, nil)
324
5x
    session := sessions.Default(c)
325
5x
    userID, ok := session.Get(middleware.UserIDKey).(int)
326
5x
    if !ok {
327
        HandleAppError(c, contextutils.ErrUnauthorized)
328
        return
329
    }
330

331
5x
    preferences, err := h.learningService.GetUserLearningPreferences(ctx, userID)
332
5x
    if err != nil {
333
1x
        h.logger.Error(ctx, "Failed to get learning preferences", err, map[string]interface{}{
334
1x
            "user_id": userID,
335
1x
        })
336
1x
        HandleAppError(c, contextutils.WrapError(err, "failed to get learning preferences"))
337
1x
        return
338
1x
    }
339

340
    // Convert backend model to API schema
341
3x
    apiPreferences := convertLearningPreferencesToAPI(preferences)
342
3x
    c.JSON(http.StatusOK, apiPreferences)
343
}
344

345
// UpdateLearningPreferences updates user learning preferences
346
8x
func (h *SettingsHandler) UpdateLearningPreferences(c *gin.Context) {
347
8x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "update_learning_preferences")
348
8x
    defer observability.FinishSpan(span, nil)
349
8x
    session := sessions.Default(c)
350
8x
    userID, ok := session.Get(middleware.UserIDKey).(int)
351
8x
    if !ok {
352
        HandleAppError(c, contextutils.ErrUnauthorized)
353
        return
354
    }
355

356
8x
    var req models.UserLearningPreferences
357
8x
    if err := c.ShouldBindJSON(&req); err != nil {
358
3x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
359
3x
            contextutils.ErrorCodeInvalidInput,
360
3x
            contextutils.SeverityWarn,
361
3x
            "Invalid request body",
362
3x
            "",
363
3x
            err,
364
3x
        ))
365
3x
        return
366
3x
    }
367

368
    // Set the user ID
369
5x
    req.UserID = userID
370
5x

371
5x
    // Set span attributes for updated preferences
372
5x
    span.SetAttributes(
373
5x
        attribute.Bool("learning.focus_on_weak_areas", req.FocusOnWeakAreas),
374
5x
        attribute.Bool("learning.include_review_questions", req.IncludeReviewQuestions),
375
5x
        attribute.Float64("learning.fresh_question_ratio", req.FreshQuestionRatio),
376
5x
        attribute.Float64("learning.known_question_penalty", req.KnownQuestionPenalty),
377
5x
        attribute.Int("learning.review_interval_days", req.ReviewIntervalDays),
378
5x
        attribute.Float64("learning.weak_area_boost", req.WeakAreaBoost),
379
5x
    )
380
5x

381
5x
    // Update preferences in database
382
5x
    updatedPrefs, err := h.learningService.UpdateUserLearningPreferences(ctx, userID, &req)
383
5x
    if err != nil {
384
1x
        h.logger.Error(ctx, "Failed to update learning preferences", err, map[string]interface{}{
385
1x
            "user_id": userID,
386
1x
        })
387
1x
        HandleAppError(c, contextutils.WrapError(err, "failed to update learning preferences"))
388
1x
        return
389
1x
    }
390

391
    // Convert backend model to API schema and return
392
3x
    apiPreferences := convertLearningPreferencesToAPI(updatedPrefs)
393
3x
    c.JSON(http.StatusOK, apiPreferences)
394
}
395

396
// SendTestEmail sends a test email to the current user
397
1x
func (h *SettingsHandler) SendTestEmail(c *gin.Context) {
398
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "send_test_email")
399
1x
    defer observability.FinishSpan(span, nil)
400
1x

401
1x
    session := sessions.Default(c)
402
1x
    userID, ok := session.Get(middleware.UserIDKey).(int)
403
1x
    if !ok {
404
        HandleAppError(c, contextutils.ErrUnauthorized)
405
        return
406
    }
407

408
    // Get the current user
409
1x
    user, err := h.userService.GetUserByID(ctx, userID)
410
1x
    if err != nil {
411
        h.logger.Error(ctx, "Failed to get user for test email", err, map[string]interface{}{
412
            "user_id": userID,
413
        })
414
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
415
        return
416
    }
417

418
    // Check if user has an email address
419
1x
    if !user.Email.Valid || user.Email.String == "" {
420
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
421
1x
        return
422
1x
    }
423

424
    // Check if email service is enabled
425
    if !h.emailService.IsEnabled() {
426
        HandleAppError(c, contextutils.ErrServiceUnavailable)
427
        return
428
    }
429

430
    // Send test email
431
    err = h.emailService.SendEmail(ctx, user.Email.String, "Test Email from Quiz App", "test_email", map[string]interface{}{
432
        "Username": user.Username,
433
        "TestTime": "now",
434
        "Message":  "This is a test email to verify your email settings are working correctly.",
435
    })
436
    if err != nil {
437
        h.logger.Error(ctx, "Failed to send test email", err, map[string]interface{}{
438
            "user_id": userID,
439
            "email":   user.Email.String,
440
        })
441
        HandleAppError(c, contextutils.WrapError(err, "failed to send test email"))
442
        return
443
    }
444

445
    h.logger.Info(ctx, "Test email sent successfully", map[string]interface{}{
446
        "user_id": userID,
447
        "email":   user.Email.String,
448
    })
449

450
    c.JSON(http.StatusOK, api.SuccessResponse{Success: true, Message: stringPtr("Test email sent successfully")})
451
}
452

453
// ClearAllStories deletes all stories belonging to the current user
454
1x
func (h *SettingsHandler) ClearAllStories(c *gin.Context) {
455
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "clear_all_stories")
456
1x
    defer observability.FinishSpan(span, nil)
457
1x
    session := sessions.Default(c)
458
1x
    userID, ok := session.Get(middleware.UserIDKey).(int)
459
1x
    if !ok {
460
        HandleAppError(c, contextutils.ErrUnauthorized)
461
        return
462
    }
463
    // Use the story service to delete all stories for this user
464
1x
    if h.storyService == nil {
465
        h.logger.Warn(ctx, "Story service not available for ClearAllStories")
466
        HandleAppError(c, contextutils.NewAppErrorWithCause(
467
            contextutils.ErrorCodeInvalidInput,
468
            contextutils.SeverityWarn,
469
            "Clear all stories not available",
470
            "",
471
            nil,
472
        ))
473
        return
474
    }
475

476
1x
    if err := h.storyService.DeleteAllStoriesForUser(ctx, uint(userID)); err != nil {
477
        h.logger.Error(ctx, "Failed to delete all stories for user", err, map[string]interface{}{"user_id": userID})
478
        HandleAppError(c, contextutils.WrapError(err, "failed to delete all stories for user"))
479
        return
480
    }
481

482
1x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "All stories deleted successfully"})
483
}
484

485
// ResetAccount deletes all stories and clears user-specific data (questions, stats)
486
1x
func (h *SettingsHandler) ResetAccount(c *gin.Context) {
487
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "reset_account")
488
1x
    defer observability.FinishSpan(span, nil)
489
1x
    session := sessions.Default(c)
490
1x
    userID, ok := session.Get(middleware.UserIDKey).(int)
491
1x
    if !ok {
492
        HandleAppError(c, contextutils.ErrUnauthorized)
493
        return
494
    }
495
    // Reset account: clear user data (questions, responses, metrics) and delete stories
496
    // First, clear user data (uses userService)
497
1x
    if err := h.userService.ClearUserDataForUser(ctx, userID); err != nil {
498
        h.logger.Error(ctx, "Failed to clear user data for user during reset", err, map[string]interface{}{"user_id": userID})
499
        HandleAppError(c, contextutils.WrapError(err, "failed to clear user data"))
500
        return
501
    }
502

503
    // Then delete all stories
504
1x
    if h.storyService == nil {
505
        h.logger.Warn(ctx, "Story service not available for ResetAccount")
506
        HandleAppError(c, contextutils.NewAppErrorWithCause(
507
            contextutils.ErrorCodeInvalidInput,
508
            contextutils.SeverityWarn,
509
            "Reset account not available",
510
            "",
511
            nil,
512
        ))
513
        return
514
    }
515

516
1x
    if err := h.storyService.DeleteAllStoriesForUser(ctx, uint(userID)); err != nil {
517
        h.logger.Error(ctx, "Failed to delete stories during reset account", err, map[string]interface{}{"user_id": userID})
518
        HandleAppError(c, contextutils.WrapError(err, "failed to delete stories during reset"))
519
        return
520
    }
521

522
1x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "Account reset successfully"})
523
}
524

525
// ClearAllAIChats deletes all AI conversations and messages for the current user
526
1x
func (h *SettingsHandler) ClearAllAIChats(c *gin.Context) {
527
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "clear_all_ai_chats")
528
1x
    defer observability.FinishSpan(span, nil)
529
1x
    session := sessions.Default(c)
530
1x
    userID, ok := session.Get(middleware.UserIDKey).(int)
531
1x
    if !ok {
532
        HandleAppError(c, contextutils.ErrUnauthorized)
533
        return
534
    }
535

536
    // Use the conversation service to delete all conversations for this user
537
1x
    if h.conversationService == nil {
538
        h.logger.Warn(ctx, "Conversation service not available for ClearAllAIChats")
539
        HandleAppError(c, contextutils.NewAppErrorWithCause(
540
            contextutils.ErrorCodeInvalidInput,
541
            contextutils.SeverityWarn,
542
            "Clear all AI chats not available",
543
            "",
544
            nil,
545
        ))
546
        return
547
    }
548

549
    // Get all conversation IDs for this user
550
1x
    conversations, _, err := h.conversationService.GetUserConversations(ctx, uint(userID), 1000, 0) // Get max 1000 to avoid issues
551
1x
    if err != nil {
552
        h.logger.Error(ctx, "Failed to get user conversations for deletion", err, map[string]interface{}{"user_id": userID})
553
        HandleAppError(c, contextutils.WrapError(err, "failed to get user conversations for deletion"))
554
        return
555
    }
556

557
    // Delete each conversation
558
1x
    deletedCount := 0
559
1x
    for _, conversation := range conversations {
560
2x
        err := h.conversationService.DeleteConversation(ctx, conversation.Id.String(), uint(userID))
561
2x
        if err != nil {
562
            h.logger.Error(ctx, "Failed to delete conversation", err, map[string]interface{}{
563
                "user_id":         userID,
564
                "conversation_id": conversation.Id.String(),
565
            })
566
            // Continue with other conversations even if one fails
567
        } else {
568
2x
            deletedCount++
569
2x
        }
570
    }
571

572
1x
    h.logger.Info(ctx, "Deleted AI conversations for user", map[string]interface{}{
573
1x
        "user_id":       userID,
574
1x
        "deleted_count": deletedCount,
575
1x
        "total_count":   len(conversations),
576
1x
    })
577
1x

578
1x
    c.JSON(http.StatusOK, api.SuccessResponse{
579
1x
        Message: stringPtr(fmt.Sprintf("Deleted %d AI conversations successfully", deletedCount)),
580
1x
        Success: true,
581
1x
    })
582
}
583

584
// ClearAllTranslationPracticeHistory deletes all translation practice history for the current user
585
1x
func (h *SettingsHandler) ClearAllTranslationPracticeHistory(c *gin.Context) {
586
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "clear_all_translation_practice_history")
587
1x
    defer observability.FinishSpan(span, nil)
588
1x
    session := sessions.Default(c)
589
1x
    userID, ok := session.Get(middleware.UserIDKey).(int)
590
1x
    if !ok {
591
        HandleAppError(c, contextutils.ErrUnauthorized)
592
        return
593
    }
594

595
    // Use the translation practice service to delete all practice history for this user
596
1x
    if h.translationPracticeService == nil {
597
        h.logger.Warn(ctx, "Translation practice service not available for ClearAllTranslationPracticeHistory")
598
        HandleAppError(c, contextutils.NewAppErrorWithCause(
599
            contextutils.ErrorCodeInvalidInput,
600
            contextutils.SeverityWarn,
601
            "Clear all translation practice history not available",
602
            "",
603
            nil,
604
        ))
605
        return
606
    }
607

608
1x
    if err := h.translationPracticeService.DeleteAllPracticeHistoryForUser(ctx, uint(userID)); err != nil {
609
        h.logger.Error(ctx, "Failed to delete all translation practice history for user", err, map[string]interface{}{"user_id": userID})
610
        HandleAppError(c, contextutils.WrapError(err, "failed to delete all translation practice history for user"))
611
        return
612
    }
613

614
1x
    c.JSON(http.StatusOK, api.SuccessResponse{
615
1x
        Message: stringPtr("All translation practice history deleted successfully"),
616
1x
        Success: true,
617
1x
    })
618
}
619


			
quizapp internal handlers worker_admin_handler.go
10.2%
Statements
34/332
1
package handlers
2

3
import (
4
    "net/http"
5
    "strconv"
6

7
    "quizapp/internal/api"
8
    "quizapp/internal/config"
9
    "quizapp/internal/observability"
10
    "quizapp/internal/services"
11
    contextutils "quizapp/internal/utils"
12

13
    "github.com/gin-gonic/gin"
14
    "go.opentelemetry.io/otel/attribute"
15
)
16

17
// SnippetsHandler handles snippets related HTTP requests
18
type SnippetsHandler struct {
19
    snippetsService services.SnippetsServiceInterface
20
    cfg             *config.Config
21
    logger          *observability.Logger
22
}
23

24
// NewSnippetsHandler creates a new SnippetsHandler instance
25
15x
func NewSnippetsHandler(snippetsService services.SnippetsServiceInterface, cfg *config.Config, logger *observability.Logger) *SnippetsHandler {
26
15x
    return &SnippetsHandler{
27
15x
        snippetsService: snippetsService,
28
15x
        cfg:             cfg,
29
15x
        logger:          logger,
30
15x
    }
31
15x
}
32

33
// CreateSnippet handles POST /v1/snippets
34
func (h *SnippetsHandler) CreateSnippet(c *gin.Context) {
35
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "create_snippet")
36
    defer observability.FinishSpan(span, nil)
37

38
    // Get user ID from context (set by auth middleware)
39
    userID, exists := GetUserIDFromSession(c)
40
    if !exists {
41
        h.logger.Warn(ctx, "User ID not found in context")
42
        HandleAppError(c, contextutils.ErrUnauthorized)
43
        return
44
    }
45
    username, exists := GetUsernameFromSession(c)
46
    if !exists {
47
        h.logger.Warn(ctx, "Username not found in context")
48
        HandleAppError(c, contextutils.ErrUnauthorized)
49
        return
50
    }
51
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
52
    span.SetAttributes(attribute.String("user.username", username))
53

54
    var req api.CreateSnippetRequest
55
    if err := c.ShouldBindJSON(&req); err != nil {
56
        h.logger.Warn(ctx, "Invalid create snippet request format", map[string]interface{}{
57
            "error": err.Error(),
58
        })
59
        HandleAppError(c, contextutils.ErrInvalidInput)
60
        return
61
    }
62

63
    snippet, err := h.snippetsService.CreateSnippet(ctx, int64(userID), req)
64
    if err != nil {
65
        h.logger.Error(ctx, "Failed to create snippet", err, map[string]interface{}{
66
            "user_id": userID,
67
        })
68

69
        HandleAppError(c, err)
70
        return
71
    }
72

73
    // Convert to API response format
74
    response := api.Snippet{
75
        Id:              &snippet.ID,
76
        UserId:          &snippet.UserID,
77
        OriginalText:    &snippet.OriginalText,
78
        TranslatedText:  &snippet.TranslatedText,
79
        SourceLanguage:  &snippet.SourceLanguage,
80
        TargetLanguage:  &snippet.TargetLanguage,
81
        QuestionId:      snippet.QuestionID,
82
        SectionId:       snippet.SectionID,
83
        StoryId:         snippet.StoryID,
84
        Context:         snippet.Context,
85
        DifficultyLevel: snippet.DifficultyLevel,
86
        CreatedAt:       &snippet.CreatedAt,
87
        UpdatedAt:       &snippet.UpdatedAt,
88
    }
89

90
    span.SetAttributes(
91
        attribute.Int64("snippet.id", snippet.ID),
92
        attribute.Int64("user.id", int64(userID)),
93
        attribute.String("snippet.original_text", snippet.OriginalText),
94
        attribute.String("snippet.translated_text", snippet.TranslatedText),
95
        attribute.String("snippet.source_language", snippet.SourceLanguage),
96
        attribute.String("snippet.target_language", snippet.TargetLanguage),
97
    )
98
    if snippet.QuestionID != nil {
99
        span.SetAttributes(attribute.Int64("snippet.question_id", *snippet.QuestionID))
100
    }
101
    if snippet.Context != nil {
102
        span.SetAttributes(attribute.String("snippet.context", *snippet.Context))
103
    }
104
    if snippet.DifficultyLevel != nil {
105
        span.SetAttributes(attribute.String("snippet.difficulty_level", *snippet.DifficultyLevel))
106
    }
107

108
    c.JSON(http.StatusCreated, response)
109
}
110

111
// GetSnippets handles GET /v1/snippets
112
7x
func (h *SnippetsHandler) GetSnippets(c *gin.Context) {
113
7x
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "get_snippets")
114
7x
    defer observability.FinishSpan(span, nil)
115
7x

116
7x
    // Get user ID from context (set by auth middleware)
117
7x
    userID, exists := GetUserIDFromSession(c)
118
7x
    if !exists {
119
        h.logger.Warn(ctx, "User ID not found in context")
120
        HandleAppError(c, contextutils.ErrUnauthorized)
121
        return
122
    }
123
7x
    username, exists := GetUsernameFromSession(c)
124
7x
    if !exists {
125
        h.logger.Warn(ctx, "Username not found in context")
126
        HandleAppError(c, contextutils.ErrUnauthorized)
127
        return
128
    }
129
7x
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
130
7x
    span.SetAttributes(attribute.String("user.username", username))
131
7x
    // Parse query parameters
132
7x
    params := api.GetV1SnippetsParams{}
133
7x

134
7x
    if q := c.Query("q"); q != "" {
135
1x
        params.Q = &q
136
1x
    }
137
7x
    if sourceLang := c.Query("source_lang"); sourceLang != "" {
138
        params.SourceLang = &sourceLang
139
    }
140
7x
    if targetLang := c.Query("target_lang"); targetLang != "" {
141
        params.TargetLang = &targetLang
142
    }
143
7x
    if storyIDStr := c.Query("story_id"); storyIDStr != "" {
144
2x
        if storyID, err := strconv.ParseInt(storyIDStr, 10, 64); err == nil {
145
1x
            params.StoryId = &storyID
146
1x
        }
147
    }
148
7x
    if level := c.Query("level"); level != "" {
149
4x
        params.Level = (*api.GetV1SnippetsParamsLevel)(&level)
150
4x
    }
151
7x
    if limitStr := c.Query("limit"); limitStr != "" {
152
        if limit, err := strconv.Atoi(limitStr); err == nil {
153
            params.Limit = &limit
154
        }
155
    }
156
7x
    if offsetStr := c.Query("offset"); offsetStr != "" {
157
        if offset, err := strconv.Atoi(offsetStr); err == nil {
158
            params.Offset = &offset
159
        }
160
    }
161
7x
    if params.Limit != nil {
162
        span.SetAttributes(attribute.Int("params.limit", *params.Limit))
163
    }
164
7x
    if params.Offset != nil {
165
        span.SetAttributes(attribute.Int("params.offset", *params.Offset))
166
    }
167
7x
    if q := params.Q; q != nil {
168
1x
        span.SetAttributes(attribute.String("params.q", *q))
169
1x
    }
170
7x
    if sourceLang := params.SourceLang; sourceLang != nil {
171
        span.SetAttributes(attribute.String("params.source_lang", *sourceLang))
172
    }
173
7x
    if targetLang := params.TargetLang; targetLang != nil {
174
        span.SetAttributes(attribute.String("params.target_lang", *targetLang))
175
    }
176
7x
    if storyID := params.StoryId; storyID != nil {
177
1x
        span.SetAttributes(attribute.Int64("params.story_id", *storyID))
178
1x
    }
179
7x
    if level := params.Level; level != nil {
180
4x
        span.SetAttributes(attribute.String("params.level", string(*level)))
181
4x
    }
182
7x
    snippetList, err := h.snippetsService.GetSnippets(ctx, int64(userID), params)
183
7x
    if err != nil {
184
        h.logger.Error(ctx, "Failed to get snippets", err, map[string]any{
185
            "user_id": userID,
186
        })
187
        HandleAppError(c, err)
188
        return
189
    }
190

191
7x
    c.JSON(http.StatusOK, snippetList)
192
}
193

194
// GetSnippetsByQuestion handles GET /v1/snippets/by-question/:question_id
195
func (h *SnippetsHandler) GetSnippetsByQuestion(c *gin.Context) {
196
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "get_snippets_by_question")
197
    defer observability.FinishSpan(span, nil)
198

199
    // Get user ID from context (set by auth middleware)
200
    userID, exists := GetUserIDFromSession(c)
201
    if !exists {
202
        h.logger.Warn(ctx, "User ID not found in context")
203
        HandleAppError(c, contextutils.ErrUnauthorized)
204
        return
205
    }
206
    username, exists := GetUsernameFromSession(c)
207
    if !exists {
208
        h.logger.Warn(ctx, "Username not found in context")
209
        HandleAppError(c, contextutils.ErrUnauthorized)
210
        return
211
    }
212
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
213
    span.SetAttributes(attribute.String("user.username", username))
214

215
    // Parse question_id from path parameter
216
    questionIDStr := c.Param("question_id")
217
    questionID, err := strconv.ParseInt(questionIDStr, 10, 64)
218
    if err != nil {
219
        h.logger.Warn(ctx, "Invalid question_id parameter", map[string]any{
220
            "question_id": questionIDStr,
221
            "error":       err.Error(),
222
        })
223
        HandleAppError(c, contextutils.ErrInvalidInput)
224
        return
225
    }
226

227
    span.SetAttributes(attribute.Int64("question.id", questionID))
228

229
    // Get snippets for this question
230
    snippets, err := h.snippetsService.GetSnippetsByQuestion(ctx, int64(userID), questionID)
231
    if err != nil {
232
        h.logger.Error(ctx, "Failed to get snippets by question", err, map[string]any{
233
            "user_id":     userID,
234
            "question_id": questionID,
235
        })
236
        HandleAppError(c, contextutils.WrapError(err, "failed to get snippets by question"))
237
        return
238
    }
239

240
    // Return response with snippets array
241
    response := gin.H{
242
        "snippets": snippets,
243
    }
244

245
    c.JSON(http.StatusOK, response)
246
}
247

248
// GetSnippetsBySection handles GET /v1/snippets/by-section/:section_id
249
func (h *SnippetsHandler) GetSnippetsBySection(c *gin.Context) {
250
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "get_snippets_by_section")
251
    defer observability.FinishSpan(span, nil)
252

253
    // Get user ID from context (set by auth middleware)
254
    userID, exists := GetUserIDFromSession(c)
255
    if !exists {
256
        h.logger.Warn(ctx, "User ID not found in context")
257
        HandleAppError(c, contextutils.ErrUnauthorized)
258
        return
259
    }
260
    username, exists := GetUsernameFromSession(c)
261
    if !exists {
262
        h.logger.Warn(ctx, "Username not found in context")
263
        HandleAppError(c, contextutils.ErrUnauthorized)
264
        return
265
    }
266
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
267
    span.SetAttributes(attribute.String("user.username", username))
268

269
    // Parse section_id from path parameter
270
    sectionIDStr := c.Param("section_id")
271
    sectionID, err := strconv.ParseInt(sectionIDStr, 10, 64)
272
    if err != nil {
273
        h.logger.Warn(ctx, "Invalid section_id parameter", map[string]any{
274
            "section_id": sectionIDStr,
275
            "error":      err.Error(),
276
        })
277
        HandleAppError(c, contextutils.ErrInvalidInput)
278
        return
279
    }
280

281
    span.SetAttributes(attribute.Int64("section.id", sectionID))
282

283
    // Get snippets for this section
284
    snippets, err := h.snippetsService.GetSnippetsBySection(ctx, int64(userID), sectionID)
285
    if err != nil {
286
        h.logger.Error(ctx, "Failed to get snippets by section", err, map[string]any{
287
            "user_id":    userID,
288
            "section_id": sectionID,
289
        })
290
        HandleAppError(c, contextutils.WrapError(err, "failed to get snippets by section"))
291
        return
292
    }
293

294
    // Return response with snippets array
295
    response := gin.H{
296
        "snippets": snippets,
297
    }
298

299
    c.JSON(http.StatusOK, response)
300
}
301

302
// GetSnippetsByStory handles GET /v1/snippets/by-story/:story_id
303
func (h *SnippetsHandler) GetSnippetsByStory(c *gin.Context) {
304
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "get_snippets_by_story")
305
    defer observability.FinishSpan(span, nil)
306

307
    // Get user ID from context (set by auth middleware)
308
    userID, exists := GetUserIDFromSession(c)
309
    if !exists {
310
        h.logger.Warn(ctx, "User ID not found in context")
311
        HandleAppError(c, contextutils.ErrUnauthorized)
312
        return
313
    }
314
    username, exists := GetUsernameFromSession(c)
315
    if !exists {
316
        h.logger.Warn(ctx, "Username not found in context")
317
        HandleAppError(c, contextutils.ErrUnauthorized)
318
        return
319
    }
320
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
321
    span.SetAttributes(attribute.String("user.username", username))
322

323
    // Parse story_id from path parameter
324
    storyIDStr := c.Param("story_id")
325
    storyID, err := strconv.ParseInt(storyIDStr, 10, 64)
326
    if err != nil {
327
        h.logger.Warn(ctx, "Invalid story_id parameter", map[string]any{
328
            "story_id": storyIDStr,
329
            "error":    err.Error(),
330
        })
331
        HandleAppError(c, contextutils.ErrInvalidInput)
332
        return
333
    }
334

335
    span.SetAttributes(attribute.Int64("story.id", storyID))
336

337
    // Get snippets for this story
338
    snippets, err := h.snippetsService.GetSnippetsByStory(ctx, int64(userID), storyID)
339
    if err != nil {
340
        h.logger.Error(ctx, "Failed to get snippets by story", err, map[string]any{
341
            "user_id":  userID,
342
            "story_id": storyID,
343
        })
344
        HandleAppError(c, contextutils.WrapError(err, "failed to get snippets by story"))
345
        return
346
    }
347

348
    // Return response with snippets array
349
    response := gin.H{
350
        "snippets": snippets,
351
    }
352

353
    c.JSON(http.StatusOK, response)
354
}
355

356
// SearchSnippets handles GET /v1/snippets/search
357
func (h *SnippetsHandler) SearchSnippets(c *gin.Context) {
358
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "search_snippets")
359
    defer observability.FinishSpan(span, nil)
360

361
    // Get user ID from context (set by auth middleware)
362
    userID, exists := GetUserIDFromSession(c)
363
    if !exists {
364
        h.logger.Warn(ctx, "User ID not found in context")
365
        HandleAppError(c, contextutils.ErrUnauthorized)
366
        return
367
    }
368
    username, exists := GetUsernameFromSession(c)
369
    if !exists {
370
        h.logger.Warn(ctx, "Username not found in context")
371
        HandleAppError(c, contextutils.ErrUnauthorized)
372
        return
373
    }
374
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
375
    span.SetAttributes(attribute.String("user.username", username))
376

377
    // Parse query parameters
378
    query := c.Query("q")
379
    if query == "" {
380
        HandleAppError(c, contextutils.ErrInvalidInput)
381
        return
382
    }
383

384
    limitStr := c.DefaultQuery("limit", "20")
385
    offsetStr := c.DefaultQuery("offset", "0")
386

387
    // Optional filters
388
    sourceLang := c.Query("source_lang")
389

390
    limit, err := strconv.Atoi(limitStr)
391
    if err != nil || limit < 1 {
392
        limit = 20
393
    }
394
    if limit > 100 {
395
        limit = 100
396
    }
397

398
    offset, err := strconv.Atoi(offsetStr)
399
    if err != nil || offset < 0 {
400
        offset = 0
401
    }
402

403
    span.SetAttributes(
404
        attribute.String("query", query),
405
        attribute.Int("limit", limit),
406
        attribute.Int("offset", offset),
407
    )
408
    if sourceLang != "" {
409
        span.SetAttributes(attribute.String("params.source_lang", sourceLang))
410
    }
411

412
    // Search snippets
413
    var sourceLangPtr *string
414
    if sourceLang != "" {
415
        sourceLangPtr = &sourceLang
416
    }
417
    snippets, total, err := h.snippetsService.SearchSnippets(ctx, int64(userID), query, limit, offset, sourceLangPtr)
418
    if err != nil {
419
        h.logger.Error(ctx, "Failed to search snippets", err, map[string]any{
420
            "user_id": userID,
421
            "query":   query,
422
            "limit":   limit,
423
            "offset":  offset,
424
        })
425
        HandleAppError(c, contextutils.WrapError(err, "failed to search snippets"))
426
        return
427
    }
428

429
    // Add metadata to response
430
    response := gin.H{
431
        "snippets": snippets,
432
        "query":    query,
433
        "total":    total,
434
        "limit":    limit,
435
        "offset":   offset,
436
    }
437

438
    c.JSON(http.StatusOK, response)
439
}
440

441
// GetSnippet handles GET /v1/snippets/{id}
442
func (h *SnippetsHandler) GetSnippet(c *gin.Context) {
443
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "get_snippet")
444
    defer observability.FinishSpan(span, nil)
445

446
    // Get user ID from context (set by auth middleware)
447
    userID, exists := GetUserIDFromSession(c)
448
    if !exists {
449
        h.logger.Warn(ctx, "User ID not found in context")
450
        HandleAppError(c, contextutils.ErrUnauthorized)
451
        return
452
    }
453
    username, exists := GetUsernameFromSession(c)
454
    if !exists {
455
        h.logger.Warn(ctx, "Username not found in context")
456
        HandleAppError(c, contextutils.ErrUnauthorized)
457
        return
458
    }
459
    span.SetAttributes(attribute.String("user.username", username))
460
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
461

462
    // Parse snippet ID from URL parameter
463
    snippetIDStr := c.Param("id")
464
    snippetID, err := strconv.ParseInt(snippetIDStr, 10, 64)
465
    if err != nil {
466
        h.logger.Warn(ctx, "Invalid snippet ID format", map[string]interface{}{
467
            "snippet_id": snippetIDStr,
468
            "error":      err.Error(),
469
        })
470
        HandleAppError(c, contextutils.ErrInvalidFormat)
471
        return
472
    }
473

474
    snippet, err := h.snippetsService.GetSnippet(ctx, int64(userID), snippetID)
475
    if err != nil {
476
        h.logger.Error(ctx, "Failed to get snippet", err, map[string]interface{}{
477
            "user_id":    userID,
478
            "snippet_id": snippetID,
479
        })
480

481
        HandleAppError(c, err)
482
        return
483
    }
484

485
    // Convert to API response format
486
    response := api.Snippet{
487
        Id:              &snippet.ID,
488
        UserId:          &snippet.UserID,
489
        OriginalText:    &snippet.OriginalText,
490
        TranslatedText:  &snippet.TranslatedText,
491
        SourceLanguage:  &snippet.SourceLanguage,
492
        TargetLanguage:  &snippet.TargetLanguage,
493
        QuestionId:      snippet.QuestionID,
494
        Context:         snippet.Context,
495
        DifficultyLevel: snippet.DifficultyLevel,
496
        CreatedAt:       &snippet.CreatedAt,
497
        UpdatedAt:       &snippet.UpdatedAt,
498
    }
499

500
    span.SetAttributes(
501
        attribute.Int64("snippet.id", snippet.ID),
502
        attribute.Int64("user.id", int64(userID)),
503
        attribute.String("user.username", username),
504
        attribute.String("snippet.original_text", snippet.OriginalText),
505
        attribute.String("snippet.translated_text", snippet.TranslatedText),
506
        attribute.String("snippet.source_language", snippet.SourceLanguage),
507
        attribute.String("snippet.target_language", snippet.TargetLanguage),
508
    )
509
    if snippet.QuestionID != nil {
510
        span.SetAttributes(attribute.Int64("snippet.question_id", *snippet.QuestionID))
511
    }
512
    if snippet.Context != nil {
513
        span.SetAttributes(attribute.String("snippet.context", *snippet.Context))
514
    }
515
    if snippet.DifficultyLevel != nil {
516
        span.SetAttributes(attribute.String("snippet.difficulty_level", *snippet.DifficultyLevel))
517
    }
518

519
    c.JSON(http.StatusOK, response)
520
}
521

522
// UpdateSnippet handles PUT /v1/snippets/{id}
523
func (h *SnippetsHandler) UpdateSnippet(c *gin.Context) {
524
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "update_snippet")
525
    defer observability.FinishSpan(span, nil)
526

527
    // Get user ID from context (set by auth middleware)
528
    userID, exists := GetUserIDFromSession(c)
529
    if !exists {
530
        h.logger.Warn(ctx, "User ID not found in context")
531
        HandleAppError(c, contextutils.ErrUnauthorized)
532
        return
533
    }
534
    username, exists := GetUsernameFromSession(c)
535
    if !exists {
536
        h.logger.Warn(ctx, "Username not found in context")
537
        HandleAppError(c, contextutils.ErrUnauthorized)
538
        return
539
    }
540
    span.SetAttributes(attribute.String("user.username", username))
541
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
542

543
    // Parse snippet ID from URL parameter
544
    snippetIDStr := c.Param("id")
545
    snippetID, err := strconv.ParseInt(snippetIDStr, 10, 64)
546
    if err != nil {
547
        h.logger.Warn(ctx, "Invalid snippet ID format", map[string]interface{}{
548
            "snippet_id": snippetIDStr,
549
            "error":      err.Error(),
550
        })
551
        HandleAppError(c, contextutils.ErrInvalidFormat)
552
        return
553
    }
554

555
    var req api.UpdateSnippetRequest
556
    if err := c.ShouldBindJSON(&req); err != nil {
557
        h.logger.Warn(ctx, "Invalid update snippet request format", map[string]interface{}{
558
            "error": err.Error(),
559
        })
560
        HandleAppError(c, contextutils.ErrInvalidInput)
561
        return
562
    }
563

564
    snippet, err := h.snippetsService.UpdateSnippet(ctx, int64(userID), snippetID, req)
565
    if err != nil {
566
        h.logger.Error(ctx, "Failed to update snippet", err, map[string]interface{}{
567
            "user_id":    userID,
568
            "snippet_id": snippetID,
569
        })
570

571
        HandleAppError(c, err)
572
        return
573
    }
574

575
    // Convert to API response format
576
    response := api.Snippet{
577
        Id:              &snippet.ID,
578
        UserId:          &snippet.UserID,
579
        OriginalText:    &snippet.OriginalText,
580
        TranslatedText:  &snippet.TranslatedText,
581
        SourceLanguage:  &snippet.SourceLanguage,
582
        TargetLanguage:  &snippet.TargetLanguage,
583
        QuestionId:      snippet.QuestionID,
584
        Context:         snippet.Context,
585
        DifficultyLevel: snippet.DifficultyLevel,
586
        CreatedAt:       &snippet.CreatedAt,
587
        UpdatedAt:       &snippet.UpdatedAt,
588
    }
589

590
    span.SetAttributes(
591
        attribute.Int64("snippet.id", snippet.ID),
592
        attribute.Int64("user.id", int64(userID)),
593
        attribute.String("user.username", username),
594
        attribute.String("snippet.original_text", snippet.OriginalText),
595
        attribute.String("snippet.translated_text", snippet.TranslatedText),
596
        attribute.String("snippet.source_language", snippet.SourceLanguage),
597
        attribute.String("snippet.target_language", snippet.TargetLanguage),
598
    )
599
    if snippet.QuestionID != nil {
600
        span.SetAttributes(attribute.Int64("snippet.question_id", *snippet.QuestionID))
601
    }
602
    if snippet.Context != nil {
603
        span.SetAttributes(attribute.String("snippet.context", *snippet.Context))
604
    }
605
    if snippet.DifficultyLevel != nil {
606
        span.SetAttributes(attribute.String("snippet.difficulty_level", *snippet.DifficultyLevel))
607
    }
608

609
    c.JSON(http.StatusOK, response)
610
}
611

612
// DeleteSnippet handles DELETE /v1/snippets/{id}
613
func (h *SnippetsHandler) DeleteSnippet(c *gin.Context) {
614
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "delete_snippet")
615
    defer observability.FinishSpan(span, nil)
616

617
    // Get user ID from context (set by auth middleware)
618
    userID, exists := GetUserIDFromSession(c)
619
    if !exists {
620
        h.logger.Warn(ctx, "User ID not found in context")
621
        HandleAppError(c, contextutils.ErrUnauthorized)
622
        return
623
    }
624
    username, exists := GetUsernameFromSession(c)
625
    if !exists {
626
        h.logger.Warn(ctx, "Username not found in context")
627
        HandleAppError(c, contextutils.ErrUnauthorized)
628
        return
629
    }
630
    span.SetAttributes(attribute.String("user.username", username))
631
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
632

633
    // Parse snippet ID from URL parameter
634
    snippetIDStr := c.Param("id")
635
    snippetID, err := strconv.ParseInt(snippetIDStr, 10, 64)
636
    if err != nil {
637
        h.logger.Warn(ctx, "Invalid snippet ID format", map[string]interface{}{
638
            "snippet_id": snippetIDStr,
639
            "error":      err.Error(),
640
        })
641
        HandleAppError(c, contextutils.ErrInvalidFormat)
642
        return
643
    }
644

645
    err = h.snippetsService.DeleteSnippet(ctx, int64(userID), snippetID)
646
    if err != nil {
647
        h.logger.Error(ctx, "Failed to delete snippet", err, map[string]interface{}{
648
            "user_id":    userID,
649
            "snippet_id": snippetID,
650
        })
651

652
        HandleAppError(c, err)
653
        return
654
    }
655

656
    span.SetAttributes(
657
        attribute.Int64("snippet.id", snippetID),
658
        attribute.Int64("user.id", int64(userID)),
659
        attribute.String("user.username", username),
660
    )
661

662
    c.Status(http.StatusNoContent)
663
}
664

665
// DeleteAllSnippets handles DELETE /v1/snippets
666
func (h *SnippetsHandler) DeleteAllSnippets(c *gin.Context) {
667
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "delete_all_snippets")
668
    defer observability.FinishSpan(span, nil)
669

670
    // Get user ID from context (set by auth middleware)
671
    userID, exists := GetUserIDFromSession(c)
672
    if !exists {
673
        h.logger.Warn(ctx, "User ID not found in context")
674
        HandleAppError(c, contextutils.ErrUnauthorized)
675
        return
676
    }
677
    username, exists := GetUsernameFromSession(c)
678
    if !exists {
679
        h.logger.Warn(ctx, "Username not found in context")
680
        HandleAppError(c, contextutils.ErrUnauthorized)
681
        return
682
    }
683
    span.SetAttributes(attribute.String("user.username", username))
684
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
685

686
    err := h.snippetsService.DeleteAllSnippets(ctx, int64(userID))
687
    if err != nil {
688
        h.logger.Error(ctx, "Failed to delete all snippets", err, map[string]interface{}{
689
            "user_id": userID,
690
        })
691

692
        HandleAppError(c, contextutils.ErrInternalError)
693
        return
694
    }
695

696
    c.Status(http.StatusNoContent)
697
}
698


			
quizapp internal handlers worker_admin_handler.go
47.9%
Statements
161/336
1
package handlers
2

3
import (
4
    "bytes"
5
    "context"
6
    "errors"
7
    "fmt"
8
    "net/http"
9
    "strconv"
10
    "strings"
11

12
    "quizapp/internal/api"
13
    "quizapp/internal/config"
14
    "quizapp/internal/models"
15
    "quizapp/internal/observability"
16
    "quizapp/internal/services"
17
    contextutils "quizapp/internal/utils"
18

19
    "github.com/gin-gonic/gin"
20
    "github.com/jung-kurt/gofpdf"
21
    "github.com/lib/pq"
22
    "go.opentelemetry.io/otel/attribute"
23
)
24

25
// StoryHandler handles story-related HTTP requests
26
type StoryHandler struct {
27
    storyService services.StoryServiceInterface
28
    userService  services.UserServiceInterface
29
    aiService    services.AIServiceInterface
30
    cfg          *config.Config
31
    logger       *observability.Logger
32
}
33

34
// NewStoryHandler creates a new StoryHandler
35
func NewStoryHandler(
36
    storyService services.StoryServiceInterface,
37
    userService services.UserServiceInterface,
38
    aiService services.AIServiceInterface,
39
    cfg *config.Config,
40
    logger *observability.Logger,
41
25x
) *StoryHandler {
42
25x
    return &StoryHandler{
43
25x
        storyService: storyService,
44
25x
        userService:  userService,
45
25x
        aiService:    aiService,
46
25x
        cfg:          cfg,
47
25x
        logger:       logger,
48
25x
    }
49
25x
}
50

51
// CreateStory handles POST /v1/story
52
8x
func (h *StoryHandler) CreateStory(c *gin.Context) {
53
8x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "create_story")
54
8x
    defer observability.FinishSpan(span, nil)
55
8x

56
8x
    userID, exists := GetUserIDFromSession(c)
57
8x
    if !exists {
58
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
59
        return
60
    }
61

62
    // userID is already int from GetUserIDFromSession
63

64
8x
    var req models.CreateStoryRequest
65
8x
    if err := c.ShouldBindJSON(&req); err != nil {
66
        h.logger.Error(ctx, "Failed to bind story creation request", err, nil)
67
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid request format", err.Error())
68
        return
69
    }
70

71
    // Get user's language preference
72
8x
    user, err := h.userService.GetUserByID(ctx, userID)
73
8x
    if err != nil {
74
        h.logger.Error(ctx, "Failed to get user", err, map[string]interface{}{
75
            "user_id": userID,
76
        })
77
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to get user information", err.Error())
78
        return
79
    }
80

81
    // Get the user's preferred language (handle sql.NullString)
82
8x
    language := "en" // default
83
8x
    if user.PreferredLanguage.Valid {
84
8x
        language = user.PreferredLanguage.String
85
8x
    }
86

87
8x
    story, err := h.storyService.CreateStory(ctx, uint(userID), language, &req)
88
8x
    if err != nil {
89
        h.logger.Error(ctx, "Failed to create story", err, map[string]interface{}{
90
            "user_id": userID,
91
            "title":   req.Title,
92
        })
93

94
        // Handle specific error cases
95
        if strings.Contains(err.Error(), "maximum archived stories limit reached") {
96
            StandardizeHTTPError(c, http.StatusForbidden, "Maximum archived stories limit reached", err.Error())
97
            return
98
        }
99

100
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to create story", err.Error())
101
        return
102
    }
103

104
8x
    span.SetAttributes(
105
8x
        attribute.String("story.title", story.Title),
106
8x
        attribute.Int("story.id", int(story.ID)),
107
8x
        attribute.String("user.language", language),
108
8x
    )
109
8x

110
8x
    // Convert to API types to ensure proper serialization
111
8x
    apiStory := convertStoryToAPI(story)
112
8x
    c.JSON(http.StatusCreated, apiStory)
113
}
114

115
// GetUserStories handles GET /v1/story
116
2x
func (h *StoryHandler) GetUserStories(c *gin.Context) {
117
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_user_stories")
118
2x
    defer observability.FinishSpan(span, nil)
119
2x

120
2x
    userID, exists := GetUserIDFromSession(c)
121
2x
    if !exists {
122
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
123
        return
124
    }
125

126
2x
    includeArchivedStr := c.Query("include_archived")
127
2x
    includeArchived := includeArchivedStr == "true"
128
2x

129
2x
    stories, err := h.storyService.GetUserStories(ctx, uint(userID), includeArchived)
130
2x
    if err != nil {
131
        h.logger.Error(ctx, "Failed to get user stories", err, map[string]interface{}{
132
            "user_id":          uint(userID),
133
            "include_archived": includeArchived,
134
        })
135
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to get stories", err.Error())
136
        return
137
    }
138

139
2x
    c.JSON(http.StatusOK, stories)
140
}
141

142
// GetCurrentStory handles GET /v1/story/current
143
9x
func (h *StoryHandler) GetCurrentStory(c *gin.Context) {
144
9x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_current_story")
145
9x
    defer observability.FinishSpan(span, nil)
146
9x

147
9x
    userID, exists := GetUserIDFromSession(c)
148
9x
    if !exists {
149
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
150
        return
151
    }
152

153
9x
    story, err := h.storyService.GetCurrentStory(ctx, uint(userID))
154
9x
    if err != nil {
155
        h.logger.Error(ctx, "Failed to get current story", err, map[string]interface{}{
156
            "user_id": uint(userID),
157
        })
158
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to get current story", err.Error())
159
        return
160
    }
161

162
9x
    if story == nil {
163
1x
        StandardizeHTTPError(c, http.StatusNotFound, "No current story found", "User has no active story")
164
1x
        return
165
1x
    }
166

167
    // If story exists but has no sections, it's generating the first section
168
8x
    if len(story.Sections) == 0 {
169
5x
        c.JSON(http.StatusAccepted, api.GeneratingResponse{
170
5x
            Status:  stringPtr("generating"),
171
5x
            Message: stringPtr("Story created successfully. The first section is being generated. Please check back shortly."),
172
5x
        })
173
5x
        return
174
5x
    }
175

176
    // If story exists and has sections, show the story content
177
    // The "generating" message should only appear when there are no sections at all
178
    // (which is handled above) or when the system is actually generating a new section
179

180
    // Record views for all sections in the story (user is accessing/reading them)
181
3x
    for _, section := range story.Sections {
182
5x
        if err := h.storyService.RecordStorySectionView(ctx, uint(userID), section.ID); err != nil {
183
            h.logger.Warn(ctx, "Failed to record story section view", map[string]interface{}{
184
                "user_id":    userID,
185
                "section_id": section.ID,
186
                "story_id":   story.ID,
187
                "error":      err.Error(),
188
            })
189
            // Don't fail the request if view recording fails
190
        }
191
    }
192

193
    // Convert to API types to ensure proper serialization
194
3x
    apiStory := convertStoryWithSectionsToAPI(story)
195
3x
    c.JSON(http.StatusOK, apiStory)
196
}
197

198
// GetStory handles GET /v1/story/:id
199
2x
func (h *StoryHandler) GetStory(c *gin.Context) {
200
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_story")
201
2x
    defer observability.FinishSpan(span, nil)
202
2x

203
2x
    userID, exists := GetUserIDFromSession(c)
204
2x
    if !exists {
205
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
206
        return
207
    }
208

209
2x
    storyIDStr := c.Param("id")
210
2x
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
211
2x
    if err != nil {
212
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
213
        return
214
    }
215

216
2x
    story, err := h.storyService.GetStory(ctx, uint(storyID), uint(userID))
217
2x
    if err != nil {
218
        h.logger.Error(ctx, "Failed to get story", err, map[string]interface{}{
219
            "story_id": storyID,
220
            "user_id":  uint(userID),
221
        })
222

223
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
224
            StandardizeHTTPError(c, http.StatusNotFound, "Story not found", "The requested story does not exist or you don't have access to it")
225
            return
226
        }
227

228
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to get story", err.Error())
229
        return
230
    }
231

232
    // Record views for all sections in the story (user is accessing/reading them)
233
2x
    for _, section := range story.Sections {
234
        if err := h.storyService.RecordStorySectionView(ctx, uint(userID), section.ID); err != nil {
235
            h.logger.Warn(ctx, "Failed to record story section view", map[string]interface{}{
236
                "user_id":    userID,
237
                "section_id": section.ID,
238
                "story_id":   storyID,
239
                "error":      err.Error(),
240
            })
241
            // Don't fail the request if view recording fails
242
        }
243
    }
244

245
    // Convert to API types to ensure proper serialization
246
2x
    apiStory := convertStoryWithSectionsToAPI(story)
247
2x
    c.JSON(http.StatusOK, apiStory)
248
}
249

250
// GetSection handles GET /v1/story/section/:id
251
func (h *StoryHandler) GetSection(c *gin.Context) {
252
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_section")
253
    defer observability.FinishSpan(span, nil)
254

255
    userID, exists := GetUserIDFromSession(c)
256
    if !exists {
257
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
258
        return
259
    }
260

261
    sectionIDStr := c.Param("id")
262
    sectionID, err := strconv.ParseUint(sectionIDStr, 10, 32)
263
    if err != nil {
264
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid section ID", "Section ID must be a valid number")
265
        return
266
    }
267

268
    section, err := h.storyService.GetSection(ctx, uint(sectionID), uint(userID))
269
    if err != nil {
270
        h.logger.Error(ctx, "Failed to get section", err, map[string]interface{}{
271
            "section_id": sectionID,
272
            "user_id":    uint(userID),
273
        })
274

275
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
276
            StandardizeHTTPError(c, http.StatusNotFound, "Section not found", "The requested section does not exist or you don't have access to it")
277
            return
278
        }
279

280
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to get section", err.Error())
281
        return
282
    }
283

284
    // Record view for this specific section (user is accessing/reading it)
285
    if err := h.storyService.RecordStorySectionView(ctx, uint(userID), uint(sectionID)); err != nil {
286
        h.logger.Warn(ctx, "Failed to record story section view", map[string]interface{}{
287
            "user_id":    userID,
288
            "section_id": sectionID,
289
            "error":      err.Error(),
290
        })
291
        // Don't fail the request if view recording fails
292
    }
293

294
    // Convert to API types to ensure proper serialization
295
    apiSection := convertStorySectionWithQuestionsToAPI(section)
296
    c.JSON(http.StatusOK, apiSection)
297
}
298

299
// GenerateNextSection handles POST /v1/story/:id/generate
300
func (h *StoryHandler) GenerateNextSection(c *gin.Context) {
301
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "generate_next_section")
302
    defer observability.FinishSpan(span, nil)
303

304
    // Create a timeout context for story generation to prevent hanging requests
305
    // Use the configured AI request timeout for consistency with other AI operations
306
    timeoutCtx, cancel := context.WithTimeout(ctx, config.AIRequestTimeout)
307
    defer cancel()
308

309
    userID, exists := GetUserIDFromSession(c)
310
    if !exists {
311
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
312
        return
313
    }
314

315
    storyIDStr := c.Param("id")
316
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
317
    if err != nil {
318
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
319
        return
320
    }
321

322
    // Get user for AI config
323
    user, err := h.userService.GetUserByID(timeoutCtx, userID)
324
    if err != nil {
325
        h.logger.Error(ctx, "Failed to get user for generation", err, map[string]interface{}{
326
            "user_id": uint(userID),
327
        })
328
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to get user information", err.Error())
329
        return
330
    }
331

332
    // Get user's AI configuration
333
    userAIConfig, apiKeyID := h.convertToServicesAIConfig(timeoutCtx, user)
334

335
    // Add user ID and API key ID to context for usage tracking
336
    timeoutCtx = contextutils.WithUserID(timeoutCtx, userID)
337
    if apiKeyID != nil {
338
        timeoutCtx = contextutils.WithAPIKeyID(timeoutCtx, *apiKeyID)
339
    }
340

341
    // Generate the story section using the shared service method (user generation)
342
    sectionWithQuestions, err := h.storyService.GenerateStorySection(timeoutCtx, uint(storyID), uint(userID), h.aiService, userAIConfig, models.GeneratorTypeUser)
343
    if err != nil {
344
        // Check if this is a generation limit reached error (normal business case)
345
        if errors.Is(err, contextutils.ErrGenerationLimitReached) {
346
            h.logger.Info(ctx, "User reached daily generation limit", map[string]interface{}{
347
                "story_id": storyID,
348
                "user_id":  uint(userID),
349
            })
350
            // Return 200 OK with business logic error instead of 409 Conflict
351
            c.JSON(http.StatusOK, api.ErrorResponse{
352
                Error:   stringPtr("You have already generated a section today for this story. Please try again tomorrow."),
353
                Details: stringPtr("daily generation limit reached"),
354
            })
355
            return
356
        }
357

358
        h.logger.Error(ctx, "Failed to generate story section", err, map[string]interface{}{
359
            "story_id": storyID,
360
            "user_id":  uint(userID),
361
        })
362

363
        // Check if this is a constraint violation (duplicate generation today)
364
        if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
365
            StandardizeHTTPError(c, http.StatusConflict, "Cannot generate section", "You have already generated a section today for this story. Please try again tomorrow.")
366
            return
367
        }
368

369
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to generate story section", err.Error())
370
        return
371
    }
372

373
    // Return success response with the generated section
374
    apiSection := convertStorySectionWithQuestionsToAPI(sectionWithQuestions)
375
    c.JSON(http.StatusCreated, apiSection)
376
}
377

378
// ArchiveStory handles POST /v1/story/:id/archive
379
6x
func (h *StoryHandler) ArchiveStory(c *gin.Context) {
380
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "archive_story")
381
6x
    defer observability.FinishSpan(span, nil)
382
6x

383
6x
    userID, exists := GetUserIDFromSession(c)
384
6x
    if !exists {
385
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
386
        return
387
    }
388

389
6x
    storyIDStr := c.Param("id")
390
6x
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
391
6x
    if err != nil {
392
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
393
        return
394
    }
395

396
6x
    err = h.storyService.ArchiveStory(ctx, uint(storyID), uint(userID))
397
6x
    if err != nil {
398
1x
        h.logger.Error(ctx, "Failed to archive story", err, map[string]interface{}{
399
1x
            "story_id": storyID,
400
1x
            "user_id":  uint(userID),
401
1x
        })
402
1x

403
1x
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
404
            StandardizeHTTPError(c, http.StatusNotFound, "Story not found", "The requested story does not exist or you don't have access to it")
405
            return
406
        }
407

408
1x
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to archive story", err.Error())
409
1x
        return
410
    }
411

412
5x
    c.JSON(http.StatusOK, gin.H{"message": "story archived successfully"})
413
}
414

415
// CompleteStory handles POST /v1/story/:id/complete
416
1x
func (h *StoryHandler) CompleteStory(c *gin.Context) {
417
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "complete_story")
418
1x
    defer observability.FinishSpan(span, nil)
419
1x

420
1x
    userID, exists := GetUserIDFromSession(c)
421
1x
    if !exists {
422
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
423
        return
424
    }
425

426
1x
    storyIDStr := c.Param("id")
427
1x
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
428
1x
    if err != nil {
429
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
430
        return
431
    }
432

433
1x
    err = h.storyService.CompleteStory(ctx, uint(storyID), uint(userID))
434
1x
    if err != nil {
435
        h.logger.Error(ctx, "Failed to complete story", err, map[string]interface{}{
436
            "story_id": storyID,
437
            "user_id":  uint(userID),
438
        })
439

440
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
441
            StandardizeHTTPError(c, http.StatusNotFound, "Story not found", "The requested story does not exist or you don't have access to it")
442
            return
443
        }
444

445
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to complete story", err.Error())
446
        return
447
    }
448

449
1x
    c.JSON(http.StatusOK, gin.H{"message": "story completed successfully"})
450
}
451

452
// SetCurrentStory handles POST /v1/story/:id/set-current
453
1x
func (h *StoryHandler) SetCurrentStory(c *gin.Context) {
454
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "set_current_story")
455
1x
    defer observability.FinishSpan(span, nil)
456
1x

457
1x
    userID, exists := GetUserIDFromSession(c)
458
1x
    if !exists {
459
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
460
        return
461
    }
462

463
1x
    storyIDStr := c.Param("id")
464
1x
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
465
1x
    if err != nil {
466
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
467
        return
468
    }
469

470
1x
    err = h.storyService.SetCurrentStory(ctx, uint(storyID), uint(userID))
471
1x
    if err != nil {
472
        h.logger.Error(ctx, "Failed to set current story", err, map[string]interface{}{
473
            "story_id": storyID,
474
            "user_id":  uint(userID),
475
        })
476

477
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
478
            StandardizeHTTPError(c, http.StatusNotFound, "Story not found", "The requested story does not exist or you don't have access to it")
479
            return
480
        }
481

482
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to set current story", err.Error())
483
        return
484
    }
485

486
1x
    c.JSON(http.StatusOK, gin.H{"message": "story set as current successfully"})
487
}
488

489
// DeleteStory handles DELETE /v1/story/:id
490
func (h *StoryHandler) DeleteStory(c *gin.Context) {
491
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "delete_story")
492
    defer observability.FinishSpan(span, nil)
493

494
    userID, exists := GetUserIDFromSession(c)
495
    if !exists {
496
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
497
        return
498
    }
499

500
    storyIDStr := c.Param("id")
501
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
502
    if err != nil {
503
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
504
        return
505
    }
506

507
    err = h.storyService.DeleteStory(ctx, uint(storyID), uint(userID))
508
    if err != nil {
509
        h.logger.Error(ctx, "Failed to delete story", err, map[string]interface{}{
510
            "story_id": storyID,
511
            "user_id":  uint(userID),
512
        })
513

514
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
515
            StandardizeHTTPError(c, http.StatusNotFound, "Story not found", "The requested story does not exist or you don't have access to it")
516
            return
517
        }
518

519
        if strings.Contains(err.Error(), "cannot delete active story") {
520
            StandardizeHTTPError(c, http.StatusConflict, "Cannot delete active story", "You cannot delete a story that is currently active")
521
            return
522
        }
523

524
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to delete story", err.Error())
525
        return
526
    }
527

528
    c.JSON(http.StatusNoContent, nil)
529
}
530

531
// ToggleAutoGeneration handles POST /v1/story/:id/toggle-auto-generation
532
8x
func (h *StoryHandler) ToggleAutoGeneration(c *gin.Context) {
533
8x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "toggle_auto_generation")
534
8x
    defer observability.FinishSpan(span, nil)
535
8x

536
8x
    userID, exists := GetUserIDFromSession(c)
537
8x
    if !exists {
538
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
539
        return
540
    }
541

542
8x
    storyIDStr := c.Param("id")
543
8x
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
544
8x
    if err != nil {
545
1x
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
546
1x
        return
547
1x
    }
548

549
    // Parse request body to get the pause state
550
7x
    var req struct {
551
7x
        Paused *bool `json:"paused" binding:"required"`
552
7x
    }
553
7x
    if err := c.ShouldBindJSON(&req); err != nil {
554
2x
        h.logger.Error(ctx, "Failed to bind toggle auto-generation request", err, nil)
555
2x
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid request format", err.Error())
556
2x
        return
557
2x
    }
558

559
5x
    if req.Paused == nil {
560
        h.logger.Error(ctx, "Missing paused field in toggle auto-generation request", nil, nil)
561
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid request format", "paused field is required")
562
        return
563
    }
564

565
5x
    err = h.storyService.ToggleAutoGeneration(ctx, uint(storyID), uint(userID), *req.Paused)
566
5x
    if err != nil {
567
2x
        h.logger.Error(ctx, "Failed to toggle auto-generation", err, map[string]interface{}{
568
2x
            "story_id": storyID,
569
2x
            "user_id":  uint(userID),
570
2x
            "paused":   *req.Paused,
571
2x
        })
572
2x

573
2x
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
574
2x
            StandardizeHTTPError(c, http.StatusNotFound, "Story not found", "The requested story does not exist or you don't have access to it")
575
2x
            return
576
2x
        }
577

578
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to toggle auto-generation", err.Error())
579
        return
580
    }
581

582
3x
    message := "Auto-generation resumed"
583
3x
    if *req.Paused {
584
2x
        message = "Auto-generation paused"
585
2x
    }
586

587
3x
    c.JSON(http.StatusOK, gin.H{"message": message, "auto_generation_paused": *req.Paused})
588
}
589

590
// ExportStory handles GET /v1/story/:id/export
591
3x
func (h *StoryHandler) ExportStory(c *gin.Context) {
592
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "export_story")
593
3x
    defer observability.FinishSpan(span, nil)
594
3x

595
3x
    userID, exists := GetUserIDFromSession(c)
596
3x
    if !exists {
597
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
598
        return
599
    }
600

601
3x
    storyIDStr := c.Param("id")
602
3x
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
603
3x
    if err != nil {
604
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
605
        return
606
    }
607

608
    // Get the story with all sections
609
3x
    story, err := h.storyService.GetStory(ctx, uint(storyID), uint(userID))
610
3x
    if err != nil {
611
1x
        h.logger.Error(ctx, "Failed to get story for export", err, map[string]interface{}{
612
1x
            "story_id": storyID,
613
1x
            "user_id":  uint(userID),
614
1x
        })
615
1x

616
1x
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
617
1x
            StandardizeHTTPError(c, http.StatusNotFound, "Story not found", "The requested story does not exist or you don't have access to it")
618
1x
            return
619
1x
        }
620

621
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to get story", err.Error())
622
        return
623
    }
624

625
    // Create PDF
626
2x
    pdf := gofpdf.New("P", "mm", "A4", "")
627
2x

628
2x
    // Use Arial (core font) for PDF generation
629
2x
    // Note: For proper Unicode support with non-Latin characters, we would need to:
630
2x
    // 1. Add a TTF font file (e.g., DejaVu Sans) to frontend/public/fonts/
631
2x
    // 2. Generate a .json font definition file using gofpdf's makefont utility
632
2x
    // 3. Register the font using pdf.AddUTF8Font()
633
2x
    // For now, Arial provides basic support and the buffer change prevents encoding issues
634
2x

635
2x
    pdf.AddPage()
636
2x
    // Use Arial consistently; size will be overridden for headings where needed
637
2x
    pdf.SetFont("Arial", "B", 16)
638
2x

639
2x
    // Add title
640
2x
    pdf.Cell(40, 10, story.Title)
641
2x
    pdf.Ln(12)
642
2x

643
2x
    // Add story metadata if present
644
2x
    pdf.SetFont("Arial", "", 10)
645
2x
    if story.Subject != nil && *story.Subject != "" {
646
2x
        pdf.Cell(40, 8, fmt.Sprintf("Subject: %s", *story.Subject))
647
2x
        pdf.Ln(6)
648
2x
    }
649
2x
    if story.AuthorStyle != nil && *story.AuthorStyle != "" {
650
2x
        pdf.Cell(40, 8, fmt.Sprintf("Style: %s", *story.AuthorStyle))
651
2x
        pdf.Ln(6)
652
2x
    }
653
2x
    if story.Genre != nil && *story.Genre != "" {
654
1x
        pdf.Cell(40, 8, fmt.Sprintf("Genre: %s", *story.Genre))
655
1x
        pdf.Ln(6)
656
1x
    }
657
2x
    pdf.Ln(5)
658
2x

659
2x
    // Add sections
660
2x
    pdf.SetFont("Arial", "", 11)
661
2x
    for _, section := range story.Sections {
662
2x
        // Section header
663
2x
        pdf.SetFont("Arial", "B", 12)
664
2x
        pdf.Cell(40, 8, fmt.Sprintf("Section %d", section.SectionNumber))
665
2x
        pdf.Ln(8)
666
2x

667
2x
        // Section content
668
2x
        pdf.SetFont("Arial", "", 11)
669
2x

670
2x
        // Split content into paragraphs (double line breaks)
671
2x
        paragraphs := strings.Split(section.Content, "\n\n")
672
2x
        for _, paragraph := range paragraphs {
673
2x
            if paragraph != "" {
674
2x
                // MultiCell for text wrapping
675
2x
                pdf.MultiCell(0, 6, paragraph, "", "L", false)
676
2x
                pdf.Ln(3)
677
2x
            }
678
        }
679
2x
        pdf.Ln(5)
680
    }
681

682
    // Set headers for PDF download
683
2x
    filename := fmt.Sprintf("story_%s.pdf", strings.ReplaceAll(strings.ToLower(story.Title), " ", "_"))
684
2x
    c.Header("Content-Type", "application/pdf")
685
2x
    c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
686
2x

687
2x
    var buf bytes.Buffer
688
2x
    err = pdf.Output(&buf)
689
2x
    if err != nil {
690
        h.logger.Error(ctx, "Failed to generate PDF", err, map[string]interface{}{
691
            "story_id": storyID,
692
        })
693
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to generate PDF", err.Error())
694
        return
695
    }
696

697
2x
    c.Data(http.StatusOK, "application/pdf", buf.Bytes())
698
}
699

700
// convertToServicesAIConfig creates AI config for the user in services format
701
func (h *StoryHandler) convertToServicesAIConfig(ctx context.Context, user *models.User) (*models.UserAIConfig, *int) {
702
    // Handle sql.NullString fields
703
    aiProvider := ""
704
    if user.AIProvider.Valid {
705
        aiProvider = user.AIProvider.String
706
    }
707

708
    aiModel := ""
709
    if user.AIModel.Valid {
710
        aiModel = user.AIModel.String
711
    }
712

713
    apiKey := ""
714
    var apiKeyID *int
715
    if aiProvider != "" {
716
        savedKey, keyID, err := h.userService.GetUserAPIKeyWithID(ctx, user.ID, aiProvider)
717
        if err == nil && savedKey != "" {
718
            apiKey = savedKey
719
            apiKeyID = keyID
720
        }
721
    }
722

723
    return &models.UserAIConfig{
724
        Provider: aiProvider,
725
        Model:    aiModel,
726
        APIKey:   apiKey,
727
        Username: user.Username,
728
    }, apiKeyID
729
}
730


			
quizapp internal handlers worker_admin_handler.go
27.3%
Statements
15/55
1
//go:build integration
2

3
package handlers
4

5
import (
6
    "context"
7
    "encoding/json"
8
    "strings"
9

10
    "quizapp/internal/config"
11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    "quizapp/internal/services"
14
    contextutils "quizapp/internal/utils"
15
)
16

17
// MockAIService implements AIServiceInterface for testing
18
type MockAIService struct {
19
    realService *services.AIService
20
}
21

22
4x
func NewMockAIService(cfg *config.Config, logger *observability.Logger) *MockAIService {
23
4x
    return &MockAIService{
24
4x
        realService: services.NewAIService(cfg, logger, services.NewNoopUsageStatsService()),
25
4x
    }
26
4x
}
27

28
// TestConnection returns a mock response for AI connection tests
29
1x
func (m *MockAIService) TestConnection(ctx context.Context, provider, model, apiKey string) error {
30
1x
    // For testing purposes, return success for valid-looking inputs
31
1x
    if provider != "" && model != "" {
32
1x
        // If it's a test API key, return an error to simulate failure
33
1x
        if strings.Contains(apiKey, "test") || apiKey == "" {
34
1x
            return contextutils.ErrorWithContextf("invalid API key")
35
1x
        }
36
        return nil
37
    }
38
    return contextutils.ErrorWithContextf("missing provider or model")
39
}
40

41
// CallWithPrompt returns a mock response for AI fix requests, otherwise delegates to real service
42
2x
func (m *MockAIService) CallWithPrompt(ctx context.Context, userConfig *models.UserAIConfig, prompt, grammar string) (string, error) {
43
2x
    // Check if this is an AI fix request by looking for fix-related keywords in the prompt
44
2x
    if strings.Contains(prompt, "fix") || strings.Contains(prompt, "Fix") ||
45
2x
        strings.Contains(prompt, "problematic") || strings.Contains(prompt, "report") {
46
2x
        // Return a mock AI fix response
47
2x
        mockResponse := map[string]interface{}{
48
2x
            "content": map[string]interface{}{
49
2x
                "question":       "What is the capital of France?",
50
2x
                "options":        []string{"Paris", "London", "Berlin", "Madrid"},
51
2x
                "correct_answer": 0,
52
2x
                "explanation":    "Paris is the capital and largest city of France.",
53
2x
            },
54
2x
            "correct_answer": 0,
55
2x
            "explanation":    "Paris is the capital and largest city of France.",
56
2x
            "change_reason":  "Fixed grammar and improved clarity of the question.",
57
2x
        }
58
2x

59
2x
        responseJSON, err := json.Marshal(mockResponse)
60
2x
        if err != nil {
61
            return "", err
62
        }
63
2x
        return string(responseJSON), nil
64
    }
65

66
    // For non-fix requests, delegate to the real service
67
    if m.realService != nil {
68
        return m.realService.CallWithPrompt(ctx, userConfig, prompt, grammar)
69
    }
70

71
    // Fallback response
72
    return `{"response": "Mock AI response"}`, nil
73
}
74

75
// Implement other required methods by delegating to real service or returning defaults
76
func (m *MockAIService) GenerateQuestion(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest) (*models.Question, error) {
77
    if m.realService != nil {
78
        return m.realService.GenerateQuestion(ctx, userConfig, req)
79
    }
80
    return nil, contextutils.ErrorWithContextf("GenerateQuestion not implemented in mock")
81
}
82

83
func (m *MockAIService) GenerateQuestions(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest) ([]*models.Question, error) {
84
    if m.realService != nil {
85
        return m.realService.GenerateQuestions(ctx, userConfig, req)
86
    }
87
    return nil, contextutils.ErrorWithContextf("GenerateQuestions not implemented in mock")
88
}
89

90
func (m *MockAIService) GenerateQuestionsStream(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest, progress chan<- *models.Question, variety *services.VarietyElements) error {
91
    if m.realService != nil {
92
        return m.realService.GenerateQuestionsStream(ctx, userConfig, req, progress, variety)
93
    }
94
    return contextutils.ErrorWithContextf("GenerateQuestionsStream not implemented in mock")
95
}
96

97
func (m *MockAIService) GenerateChatResponse(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIChatRequest) (string, error) {
98
    if m.realService != nil {
99
        return m.realService.GenerateChatResponse(ctx, userConfig, req)
100
    }
101
    return "Mock chat response", nil
102
}
103

104
func (m *MockAIService) GenerateChatResponseStream(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIChatRequest, chunks chan<- string) error {
105
    if m.realService != nil {
106
        return m.realService.GenerateChatResponseStream(ctx, userConfig, req, chunks)
107
    }
108
    select {
109
    case chunks <- "Mock streaming response":
110
    default:
111
    }
112
    return nil
113
}
114

115
1x
func (m *MockAIService) GetConcurrencyStats() services.ConcurrencyStats {
116
1x
    if m.realService != nil {
117
1x
        return m.realService.GetConcurrencyStats()
118
1x
    }
119
    return services.ConcurrencyStats{}
120
}
121

122
func (m *MockAIService) GetQuestionBatchSize(provider string) int {
123
    if m.realService != nil {
124
        return m.realService.GetQuestionBatchSize(provider)
125
    }
126
    return 1
127
}
128

129
func (m *MockAIService) VarietyService() *services.VarietyService {
130
    if m.realService != nil {
131
        return m.realService.VarietyService()
132
    }
133
    return nil
134
}
135

136
4x
func (m *MockAIService) TemplateManager() *services.AITemplateManager {
137
4x
    if m.realService != nil {
138
4x
        return m.realService.TemplateManager()
139
4x
    }
140
    return nil
141
}
142

143
func (m *MockAIService) GenerateStoryQuestions(ctx context.Context, userConfig *models.UserAIConfig, req *models.StoryQuestionsRequest) ([]*models.StorySectionQuestionData, error) {
144
    if m.realService != nil {
145
        return m.realService.GenerateStoryQuestions(ctx, userConfig, req)
146
    }
147
    // Return mock data for testing
148
    return []*models.StorySectionQuestionData{
149
        {
150
            QuestionText:       "What is the main character doing?",
151
            Options:            []string{"Reading", "Writing", "Running", "Swimming"},
152
            CorrectAnswerIndex: 0,
153
            Explanation:        stringPtr("The main character is reading a book"),
154
        },
155
    }, nil
156
}
157

158
func (m *MockAIService) GenerateStorySection(ctx context.Context, userConfig *models.UserAIConfig, req *models.StoryGenerationRequest) (string, error) {
159
    if m.realService != nil {
160
        return m.realService.GenerateStorySection(ctx, userConfig, req)
161
    }
162
    // Return mock data for testing
163
    return "Once upon a time, there was a brave knight who lived in a castle...", nil
164
}
165

166
2x
func (m *MockAIService) SupportsGrammarField(provider string) bool {
167
2x
    if m.realService != nil {
168
2x
        return m.realService.SupportsGrammarField(provider)
169
2x
    }
170
    return false
171
}
172

173
func (m *MockAIService) Shutdown(ctx context.Context) error {
174
    if m.realService != nil {
175
        return m.realService.Shutdown(ctx)
176
    }
177
    return nil
178
}
179


			
quizapp internal handlers worker_admin_handler.go
2.4%
Statements
1/42
1
package handlers
2

3
import (
4
    "context"
5
    "net/http"
6

7
    "quizapp/internal/api"
8
    "quizapp/internal/config"
9
    "quizapp/internal/middleware"
10
    "quizapp/internal/observability"
11
    "quizapp/internal/serviceinterfaces"
12
    "quizapp/internal/services"
13
    contextutils "quizapp/internal/utils"
14

15
    "github.com/gin-gonic/gin"
16
    "go.opentelemetry.io/otel/attribute"
17
)
18

19
// stringPtrOrEmpty returns the string value if not nil, otherwise returns empty string
20
func stringPtrOrEmpty(s *string) string {
21
    if s == nil {
22
        return ""
23
    }
24
    return *s
25
}
26

27
// TranslationHandler handles translation related HTTP requests
28
type TranslationHandler struct {
29
    translationService services.TranslationServiceInterface
30
    cfg                *config.Config
31
    logger             *observability.Logger
32
}
33

34
// NewTranslationHandler creates a new TranslationHandler instance
35
14x
func NewTranslationHandler(translationService services.TranslationServiceInterface, cfg *config.Config, logger *observability.Logger) *TranslationHandler {
36
14x
    return &TranslationHandler{
37
14x
        translationService: translationService,
38
14x
        cfg:                cfg,
39
14x
        logger:             logger,
40
14x
    }
41
14x
}
42

43
// TranslateText handles text translation requests
44
func (h *TranslationHandler) TranslateText(c *gin.Context) {
45
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "translate_text")
46
    defer observability.FinishSpan(span, nil)
47

48
    var req api.TranslateRequest
49
    if err := c.ShouldBindJSON(&req); err != nil {
50
        h.logger.Warn(ctx, "Invalid translation request format", map[string]interface{}{"error": err.Error()})
51
        c.JSON(http.StatusBadRequest, api.ErrorResponse{
52
            Code:    stringPtr("INVALID_REQUEST"),
53
            Message: stringPtr("Invalid request format"),
54
            Error:   stringPtr(err.Error()),
55
        })
56
        return
57
    }
58

59
    // Validate input
60
    if err := h.validateTranslationRequest(ctx, req); err != nil {
61
        h.logger.Warn(ctx, "Translation request validation failed", map[string]interface{}{"error": err.Error()})
62
        c.JSON(http.StatusBadRequest, api.ErrorResponse{
63
            Code:    stringPtr("VALIDATION_ERROR"),
64
            Message: stringPtr("Request validation failed"),
65
            Error:   stringPtr(err.Error()),
66
        })
67
        return
68
    }
69

70
    // Set span attributes for observability
71
    span.SetAttributes(
72
        attribute.String("translation.target_language", req.TargetLanguage),
73
        attribute.String("translation.source_language", stringPtrOrEmpty(req.SourceLanguage)),
74
        attribute.Int("translation.text_length", len(req.Text)),
75
    )
76

77
    // Perform translation
78
    response, err := h.translationService.Translate(ctx, serviceinterfaces.TranslateRequest{
79
        Text:           req.Text,
80
        TargetLanguage: req.TargetLanguage,
81
        SourceLanguage: stringPtrOrEmpty(req.SourceLanguage),
82
    })
83
    if err != nil {
84
        h.logger.Error(ctx, "Translation failed", err)
85

86
        // Check if it's a service unavailable error
87
        if contextutils.GetErrorCode(err) == contextutils.ErrorCodeServiceUnavailable {
88
            c.JSON(http.StatusServiceUnavailable, api.ErrorResponse{
89
                Code:    stringPtr("TRANSLATION_SERVICE_UNAVAILABLE"),
90
                Message: stringPtr("Translation service is currently unavailable"),
91
                Error:   stringPtr(err.Error()),
92
            })
93
            return
94
        }
95

96
        // Default to bad request for other errors
97
        c.JSON(http.StatusBadRequest, api.ErrorResponse{
98
            Code:    stringPtr("TRANSLATION_FAILED"),
99
            Message: stringPtr("Translation failed"),
100
            Error:   stringPtr(err.Error()),
101
        })
102
        return
103
    }
104

105
    // Return successful response
106
    var confidencePtr *float32
107
    if response.Confidence > 0 {
108
        conf := float32(response.Confidence)
109
        confidencePtr = &conf
110
    }
111
    c.JSON(http.StatusOK, api.TranslateResponse{
112
        TranslatedText: response.TranslatedText,
113
        SourceLanguage: response.SourceLanguage,
114
        TargetLanguage: response.TargetLanguage,
115
        Confidence:     confidencePtr,
116
    })
117
}
118

119
// validateTranslationRequest validates the translation request
120
func (h *TranslationHandler) validateTranslationRequest(_ context.Context, req api.TranslateRequest) error {
121
    // Validate text length
122
    if len(req.Text) == 0 {
123
        return contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Text cannot be empty", "")
124
    }
125

126
    if len(req.Text) > 5000 {
127
        return contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Text cannot exceed 5000 characters", "")
128
    }
129

130
    // Validate target language
131
    if err := h.translationService.ValidateLanguageCode(req.TargetLanguage); err != nil {
132
        return contextutils.WrapError(err, "Invalid target language")
133
    }
134

135
    // Validate source language if provided
136
    if req.SourceLanguage != nil && *req.SourceLanguage != "" {
137
        if err := h.translationService.ValidateLanguageCode(*req.SourceLanguage); err != nil {
138
            return contextutils.WrapError(err, "Invalid source language")
139
        }
140
    }
141

142
    return nil
143
}
144

145
// RegisterRoutes registers the translation routes with the router
146
func (h *TranslationHandler) RegisterRoutes(router *gin.Engine) {
147
    v1 := router.Group("/v1")
148
    {
149
        v1.POST("/translate", middleware.RequireAuth(), h.TranslateText)
150
    }
151
}
152


			
quizapp internal handlers worker_admin_handler.go
45.6%
Statements
72/158
1
package handlers
2

3
import (
4
    "context"
5
    "fmt"
6
    "net/http"
7

8
    "quizapp/internal/api"
9
    "quizapp/internal/config"
10
    "quizapp/internal/middleware"
11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    "quizapp/internal/services"
14
    contextutils "quizapp/internal/utils"
15

16
    "github.com/gin-gonic/gin"
17
)
18

19
// TranslationPracticeHandler handles translation practice related HTTP requests
20
type TranslationPracticeHandler struct {
21
    translationPracticeService services.TranslationPracticeServiceInterface
22
    aiService                  services.AIServiceInterface
23
    userService                services.UserServiceInterface
24
    cfg                        *config.Config
25
    logger                     *observability.Logger
26
}
27

28
// convertToServicesAIConfig creates AI config for the user in services format,
29
// reusing the same approach as other handlers (e.g., story/quiz) including
30
// fetching the saved per-provider API key.
31
func (h *TranslationPracticeHandler) convertToServicesAIConfig(ctx context.Context, user *models.User) *models.UserAIConfig {
32
    aiProvider := ""
33
    if user.AIProvider.Valid {
34
        aiProvider = user.AIProvider.String
35
    }
36
    aiModel := ""
37
    if user.AIModel.Valid {
38
        aiModel = user.AIModel.String
39
    }
40
    apiKey := ""
41
    if aiProvider != "" {
42
        savedKey, _, err := h.userService.GetUserAPIKeyWithID(ctx, user.ID, aiProvider)
43
        if err == nil && savedKey != "" {
44
            apiKey = savedKey
45
        }
46
    }
47
    return &models.UserAIConfig{
48
        Provider: aiProvider,
49
        Model:    aiModel,
50
        APIKey:   apiKey,
51
        Username: user.Username,
52
    }
53
}
54

55
// NewTranslationPracticeHandler creates a new TranslationPracticeHandler instance
56
func NewTranslationPracticeHandler(
57
    translationPracticeService services.TranslationPracticeServiceInterface,
58
    aiService services.AIServiceInterface,
59
    userService services.UserServiceInterface,
60
    cfg *config.Config,
61
    logger *observability.Logger,
62
14x
) *TranslationPracticeHandler {
63
14x
    return &TranslationPracticeHandler{
64
14x
        translationPracticeService: translationPracticeService,
65
14x
        aiService:                  aiService,
66
14x
        userService:                userService,
67
14x
        cfg:                        cfg,
68
14x
        logger:                     logger,
69
14x
    }
70
14x
}
71

72
// GenerateSentence handles requests to generate a new sentence for translation practice
73
func (h *TranslationPracticeHandler) GenerateSentence(c *gin.Context) {
74
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "generate_translation_sentence")
75
    defer observability.FinishSpan(span, nil)
76

77
    userIDInt, exists := GetUserIDFromSession(c)
78
    if !exists {
79
        HandleAppError(c, contextutils.ErrUnauthorized)
80
        return
81
    }
82
    userID := uint(userIDInt)
83

84
    var req api.TranslationPracticeGenerateRequest
85
    if err := c.ShouldBindJSON(&req); err != nil {
86
        h.logger.Warn(ctx, "Invalid generate sentence request format", map[string]interface{}{"error": err.Error()})
87
        c.JSON(http.StatusBadRequest, api.ErrorResponse{
88
            Code:    stringPtr("INVALID_REQUEST"),
89
            Message: stringPtr("Invalid request format"),
90
            Error:   stringPtr(err.Error()),
91
        })
92
        return
93
    }
94

95
    // Get user for AI config
96
    user, err := h.userService.GetUserByID(ctx, int(userID))
97
    if err != nil || user == nil {
98
        HandleAppError(c, contextutils.ErrRecordNotFound)
99
        return
100
    }
101

102
    userAIConfig := h.convertToServicesAIConfig(ctx, user)
103

104
    // Convert API request to service request
105
    serviceReq := &models.GenerateSentenceRequest{
106
        Language:  req.Language,
107
        Level:     req.Level,
108
        Direction: models.TranslationDirection(req.Direction),
109
        Topic:     req.Topic,
110
    }
111

112
    sentence, err := h.translationPracticeService.GenerateSentence(ctx, userID, serviceReq, h.aiService, userAIConfig)
113
    if err != nil {
114
        h.logger.Error(ctx, "Failed to generate sentence", err)
115
        HandleAppError(c, err)
116
        return
117
    }
118

119
    c.JSON(http.StatusOK, api.TranslationPracticeSentenceResponse{
120
        Id:             int(sentence.ID),
121
        SentenceText:   sentence.SentenceText,
122
        SourceLanguage: sentence.SourceLanguage,
123
        TargetLanguage: sentence.TargetLanguage,
124
        LanguageLevel:  sentence.LanguageLevel,
125
        SourceType:     string(sentence.SourceType),
126
        SourceId:       intPtrFromUintPtr(sentence.SourceID),
127
        Topic:          sentence.Topic,
128
        CreatedAt:      sentence.CreatedAt,
129
    })
130
}
131

132
// GetSentence handles requests to get a sentence from existing content
133
1x
func (h *TranslationPracticeHandler) GetSentence(c *gin.Context) {
134
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_translation_sentence")
135
1x
    defer observability.FinishSpan(span, nil)
136
1x

137
1x
    userIDInt, exists := GetUserIDFromSession(c)
138
1x
    if !exists {
139
        HandleAppError(c, contextutils.ErrUnauthorized)
140
        return
141
    }
142
1x
    userID := uint(userIDInt)
143
1x

144
1x
    // Get query parameters
145
1x
    language := c.Query("language")
146
1x
    level := c.Query("level")
147
1x
    direction := c.Query("direction")
148
1x

149
1x
    if language == "" || level == "" || direction == "" {
150
        c.JSON(http.StatusBadRequest, api.ErrorResponse{
151
            Code:    stringPtr("INVALID_REQUEST"),
152
            Message: stringPtr("Missing required parameters: language, level, direction"),
153
        })
154
        return
155
    }
156

157
1x
    sentence, err := h.translationPracticeService.GetSentenceFromExistingContent(
158
1x
        ctx,
159
1x
        userID,
160
1x
        language,
161
1x
        level,
162
1x
        models.TranslationDirection(direction),
163
1x
    )
164
1x
    if err != nil {
165
        h.logger.Error(ctx, "Failed to get sentence from existing content", err)
166
        HandleAppError(c, err)
167
        return
168
    }
169

170
1x
    c.JSON(http.StatusOK, api.TranslationPracticeSentenceResponse{
171
1x
        Id:             int(sentence.ID),
172
1x
        SentenceText:   sentence.SentenceText,
173
1x
        SourceLanguage: sentence.SourceLanguage,
174
1x
        TargetLanguage: sentence.TargetLanguage,
175
1x
        LanguageLevel:  sentence.LanguageLevel,
176
1x
        SourceType:     string(sentence.SourceType),
177
1x
        SourceId:       intPtrFromUintPtr(sentence.SourceID),
178
1x
        Topic:          sentence.Topic,
179
1x
        CreatedAt:      sentence.CreatedAt,
180
1x
    })
181
}
182

183
// SubmitTranslation handles requests to submit a translation for evaluation
184
func (h *TranslationPracticeHandler) SubmitTranslation(c *gin.Context) {
185
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "submit_translation")
186
    defer observability.FinishSpan(span, nil)
187

188
    userIDInt, exists := GetUserIDFromSession(c)
189
    if !exists {
190
        HandleAppError(c, contextutils.ErrUnauthorized)
191
        return
192
    }
193
    userID := uint(userIDInt)
194

195
    var req api.TranslationPracticeSubmitRequest
196
    if err := c.ShouldBindJSON(&req); err != nil {
197
        h.logger.Warn(ctx, "Invalid submit translation request format", map[string]interface{}{"error": err.Error()})
198
        c.JSON(http.StatusBadRequest, api.ErrorResponse{
199
            Code:    stringPtr("INVALID_REQUEST"),
200
            Message: stringPtr("Invalid request format"),
201
            Error:   stringPtr(err.Error()),
202
        })
203
        return
204
    }
205

206
    // Get user for AI config
207
    user, err := h.userService.GetUserByID(ctx, int(userID))
208
    if err != nil || user == nil {
209
        HandleAppError(c, contextutils.ErrRecordNotFound)
210
        return
211
    }
212

213
    userAIConfig := h.convertToServicesAIConfig(ctx, user)
214

215
    // Convert API request to service request
216
    serviceReq := &models.SubmitTranslationRequest{
217
        SentenceID:           uint(req.SentenceId),
218
        OriginalSentence:     req.OriginalSentence,
219
        UserTranslation:      req.UserTranslation,
220
        TranslationDirection: models.TranslationDirection(req.TranslationDirection),
221
    }
222

223
    session, err := h.translationPracticeService.SubmitTranslation(ctx, userID, serviceReq, h.aiService, userAIConfig)
224
    if err != nil {
225
        h.logger.Error(ctx, "Failed to submit translation", err)
226
        HandleAppError(c, err)
227
        return
228
    }
229

230
    c.JSON(http.StatusOK, api.TranslationPracticeSessionResponse{
231
        Id:                   int(session.ID),
232
        SentenceId:           int(session.SentenceID),
233
        OriginalSentence:     session.OriginalSentence,
234
        UserTranslation:      session.UserTranslation,
235
        TranslationDirection: string(session.TranslationDirection),
236
        AiFeedback:           session.AIFeedback,
237
        AiScore:              float64PtrTo32(session.AIScore),
238
        CreatedAt:            session.CreatedAt,
239
    })
240
}
241

242
// GetHistory handles requests to get translation practice history
243
18x
func (h *TranslationPracticeHandler) GetHistory(c *gin.Context) {
244
18x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_translation_practice_history")
245
18x
    defer observability.FinishSpan(span, nil)
246
18x

247
18x
    userIDInt, exists := GetUserIDFromSession(c)
248
18x
    if !exists {
249
        HandleAppError(c, contextutils.ErrUnauthorized)
250
        return
251
    }
252
18x
    userID := uint(userIDInt)
253
18x

254
18x
    limit := 50 // Default limit
255
18x
    if limitStr := c.Query("limit"); limitStr != "" {
256
9x
        if parsedLimit, err := parseInt(limitStr); err == nil && parsedLimit > 0 && parsedLimit <= 100 {
257
8x
            limit = parsedLimit
258
8x
        }
259
    }
260

261
18x
    offset := 0 // Default offset
262
18x
    if offsetStr := c.Query("offset"); offsetStr != "" {
263
7x
        if parsedOffset, err := parseInt(offsetStr); err == nil && parsedOffset >= 0 {
264
6x
            offset = parsedOffset
265
6x
        }
266
    }
267

268
18x
    search := c.Query("search")
269
18x

270
18x
    sessions, total, err := h.translationPracticeService.GetPracticeHistory(ctx, userID, limit, offset, search)
271
18x
    if err != nil {
272
        h.logger.Error(ctx, "Failed to get practice history", err)
273
        HandleAppError(c, err)
274
        return
275
    }
276

277
18x
    response := make([]api.TranslationPracticeSessionResponse, len(sessions))
278
18x
    for i, session := range sessions {
279
176x
        response[i] = api.TranslationPracticeSessionResponse{
280
176x
            Id:                   int(session.ID),
281
176x
            SentenceId:           int(session.SentenceID),
282
176x
            OriginalSentence:     session.OriginalSentence,
283
176x
            UserTranslation:      session.UserTranslation,
284
176x
            TranslationDirection: string(session.TranslationDirection),
285
176x
            AiFeedback:           session.AIFeedback,
286
176x
            AiScore:              float64PtrTo32(session.AIScore),
287
176x
            CreatedAt:            session.CreatedAt,
288
176x
        }
289
176x
    }
290

291
18x
    c.JSON(http.StatusOK, api.TranslationPracticeHistoryResponse{
292
18x
        Sessions: response,
293
18x
        Total:    total,
294
18x
        Limit:    limit,
295
18x
        Offset:   offset,
296
18x
    })
297
}
298

299
// GetStats handles requests to get translation practice statistics
300
1x
func (h *TranslationPracticeHandler) GetStats(c *gin.Context) {
301
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_translation_practice_stats")
302
1x
    defer observability.FinishSpan(span, nil)
303
1x

304
1x
    userIDInt, exists := GetUserIDFromSession(c)
305
1x
    if !exists {
306
        HandleAppError(c, contextutils.ErrUnauthorized)
307
        return
308
    }
309
1x
    userID := uint(userIDInt)
310
1x

311
1x
    stats, err := h.translationPracticeService.GetPracticeStats(ctx, userID)
312
1x
    if err != nil {
313
        h.logger.Error(ctx, "Failed to get practice stats", err)
314
        HandleAppError(c, err)
315
        return
316
    }
317

318
    // Coerce nullable numeric fields to numbers to satisfy response schema
319
1x
    response := map[string]interface{}{}
320
1x
    // counts (always integers)
321
1x
    if v, ok := stats["total_sessions"]; ok {
322
1x
        response["total_sessions"] = v
323
1x
    } else {
324
        response["total_sessions"] = 0
325
    }
326
1x
    if v, ok := stats["excellent_count"]; ok {
327
1x
        response["excellent_count"] = v
328
1x
    } else {
329
        response["excellent_count"] = 0
330
    }
331
1x
    if v, ok := stats["good_count"]; ok {
332
1x
        response["good_count"] = v
333
1x
    } else {
334
        response["good_count"] = 0
335
    }
336
1x
    if v, ok := stats["needs_improvement_count"]; ok {
337
1x
        response["needs_improvement_count"] = v
338
1x
    } else {
339
        response["needs_improvement_count"] = 0
340
    }
341
    // numeric (float) values; convert nil to 0.0
342
1x
    if v, ok := stats["average_score"]; ok && v != nil {
343
1x
        response["average_score"] = v
344
1x
    } else {
345
        response["average_score"] = 0.0
346
    }
347
1x
    if v, ok := stats["min_score"]; ok && v != nil {
348
1x
        response["min_score"] = v
349
1x
    } else {
350
        response["min_score"] = 0.0
351
    }
352
1x
    if v, ok := stats["max_score"]; ok && v != nil {
353
1x
        response["max_score"] = v
354
1x
    } else {
355
        response["max_score"] = 0.0
356
    }
357

358
1x
    c.JSON(http.StatusOK, response)
359
}
360

361
// RegisterRoutes registers the translation practice routes with the router
362
14x
func (h *TranslationPracticeHandler) RegisterRoutes(router *gin.Engine) {
363
14x
    v1 := router.Group("/v1")
364
14x
    {
365
14x
        v1.POST("/translation-practice/generate", middleware.RequireAuth(), h.GenerateSentence)
366
14x
        v1.GET("/translation-practice/sentence", middleware.RequireAuth(), h.GetSentence)
367
14x
        v1.POST("/translation-practice/submit", middleware.RequireAuth(), h.SubmitTranslation)
368
14x
        v1.GET("/translation-practice/history", middleware.RequireAuth(), h.GetHistory)
369
14x
        v1.GET("/translation-practice/stats", middleware.RequireAuth(), h.GetStats)
370
14x
    }
371
}
372

373
// Helper functions
374

375
1x
func intPtrFromUintPtr(ptr *uint) *int {
376
1x
    if ptr == nil {
377
        return nil
378
    }
379
1x
    val := int(*ptr)
380
1x
    return &val
381
}
382

383
16x
func parseInt(s string) (int, error) {
384
16x
    var i int
385
16x
    _, err := fmt.Sscanf(s, "%d", &i)
386
16x
    return i, err
387
16x
}
388

389
176x
func float64PtrTo32(p *float64) *float32 {
390
176x
    if p == nil {
391
        return nil
392
    }
393
176x
    v := float32(*p)
394
176x
    return &v
395
}
396


			
quizapp internal handlers worker_admin_handler.go
44.6%
Statements
181/406
1
package handlers
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "fmt"
8
    "html/template"
9
    "net/http"
10
    "strconv"
11
    "strings"
12
    "time"
13

14
    "quizapp/internal/api"
15
    "quizapp/internal/config"
16
    "quizapp/internal/models"
17
    "quizapp/internal/observability"
18
    "quizapp/internal/services"
19
    contextutils "quizapp/internal/utils"
20

21
    "github.com/gin-gonic/gin"
22
)
23

24
// UserAdminHandler handles user management operations
25
type UserAdminHandler struct {
26
    userService services.UserServiceInterface
27
    cfg         *config.Config
28
    templates   *template.Template
29
    logger      *observability.Logger
30
}
31

32
// NewUserAdminHandler creates a new UserAdminHandler instance
33
14x
func NewUserAdminHandler(userService services.UserServiceInterface, cfg *config.Config, logger *observability.Logger) *UserAdminHandler {
34
14x
    return &UserAdminHandler{
35
14x
        userService: userService,
36
14x
        cfg:         cfg,
37
14x
        templates:   nil,
38
14x
        logger:      logger,
39
14x
    }
40
14x
}
41

42
// UserCreateRequest represents a request to create a new user
43
// Using the generated type from api package for automatic validation
44
type UserCreateRequest = api.UserCreateRequest
45

46
// UserUpdateRequest represents a request to update user profile
47
// Using the generated type from api package for automatic validation
48
type UserUpdateRequest = api.UserUpdateRequest
49

50
// PasswordResetRequest represents a request to reset user password
51
// Using the generated type from api package for automatic validation
52
type PasswordResetRequest = api.PasswordResetRequest
53

54
// ProfileResponse represents user profile data
55
type ProfileResponse struct {
56
    ID                int           `json:"id"`
57
    Username          string        `json:"username"`
58
    Email             *string       `json:"email"`
59
    Timezone          *string       `json:"timezone"`
60
    LastActive        *time.Time    `json:"last_active"`
61
    PreferredLanguage *string       `json:"preferred_language"`
62
    CurrentLevel      *string       `json:"current_level"`
63
    CreatedAt         time.Time     `json:"created_at"`
64
    UpdatedAt         time.Time     `json:"updated_at"`
65
    AIEnabled         bool          `json:"ai_enabled"`
66
    AIProvider        *string       `json:"ai_provider"`
67
    AIModel           *string       `json:"ai_model"`
68
    Roles             []models.Role `json:"roles,omitempty"`
69
    IsPaused          bool          `json:"is_paused"`
70
}
71

72
// GetAllUsers handles GET /userz - list all users (admin only) - JSON API
73
1x
func (h *UserAdminHandler) GetAllUsers(c *gin.Context) {
74
1x
    users, err := h.userService.GetAllUsers(c.Request.Context())
75
1x
    if err != nil {
76
        h.logger.Error(c.Request.Context(), "Error retrieving users", err, nil)
77
        HandleAppError(c, contextutils.WrapError(err, "failed to retrieve users"))
78
        return
79
    }
80

81
    // Convert to response format
82
1x
    var userResponses []ProfileResponse
83
1x
    for _, user := range users {
84
3x
        userResponses = append(userResponses, h.convertUserToProfileResponse(c.Request.Context(), &user))
85
3x
    }
86

87
1x
    c.JSON(http.StatusOK, gin.H{"users": userResponses})
88
}
89

90
// GetUsersPaginated handles GET /userz/paginated - list users with pagination (admin only)
91
func (h *UserAdminHandler) GetUsersPaginated(c *gin.Context) {
92
    // Parse pagination parameters
93
    page, pageSize := h.parsePagination(c)
94

95
    // Parse filters
96
    search := c.Query("search")
97
    language := c.Query("language")
98
    level := c.Query("level")
99
    aiProvider := c.Query("ai_provider")
100
    aiModel := c.Query("ai_model")
101
    aiEnabled := c.Query("ai_enabled")
102
    active := c.Query("active")
103

104
    // Get paginated users from service
105
    var users []models.User
106
    var total int
107
    var err error
108
    users, total, err = h.userService.GetUsersPaginated(
109
        c.Request.Context(),
110
        page,
111
        pageSize,
112
        search,
113
        language,
114
        level,
115
        aiProvider,
116
        aiModel,
117
        aiEnabled,
118
        active,
119
    )
120
    if err != nil {
121
        h.logger.Error(c.Request.Context(), "Error retrieving paginated users", err, map[string]interface{}{
122
            "page":      page,
123
            "page_size": pageSize,
124
            "search":    search,
125
        })
126
        HandleAppError(c, contextutils.WrapError(err, "failed to retrieve users"))
127
        return
128
    }
129

130
    // Convert to response format matching swagger specification
131
    var userItems []gin.H
132
    for _, user := range users {
133
        profileResponse := h.convertUserToProfileResponse(c.Request.Context(), &user)
134

135
        // Create user item with nested user object as per swagger spec
136
        userItem := gin.H{
137
            "user": profileResponse,
138
        }
139
        userItems = append(userItems, userItem)
140
    }
141

142
    // Calculate pagination info
143
    totalPages := (total + pageSize - 1) / pageSize
144

145
    c.JSON(http.StatusOK, gin.H{
146
        "users": userItems,
147
        "pagination": gin.H{
148
            "page":        page,
149
            "page_size":   pageSize,
150
            "total":       total,
151
            "total_pages": totalPages,
152
        },
153
    })
154
}
155

156
// parsePagination parses pagination parameters from the request
157
func (h *UserAdminHandler) parsePagination(c *gin.Context) (page, pageSize int) {
158
    page = 1
159
    pageSize = 20
160

161
    if pageStr := c.Query("page"); pageStr != "" {
162
        if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
163
            page = p
164
        }
165
    }
166

167
    if pageSizeStr := c.Query("page_size"); pageSizeStr != "" {
168
        if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 100 {
169
            pageSize = ps
170
        }
171
    }
172

173
    return page, pageSize
174
}
175

176
// CreateUser handles POST /userz - create new user (admin only)
177
4x
func (h *UserAdminHandler) CreateUser(c *gin.Context) {
178
4x
    var req UserCreateRequest
179
4x
    if err := c.ShouldBindJSON(&req); err != nil {
180
        HandleAppError(c, contextutils.NewAppErrorWithCause(
181
            contextutils.ErrorCodeInvalidInput,
182
            contextutils.SeverityWarn,
183
            "Invalid request data",
184
            "",
185
            err,
186
        ))
187
        return
188
    }
189

190
    // Validate required fields
191
4x
    if req.Username == "" {
192
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
193
1x
        return
194
1x
    }
195
3x
    if req.Password == "" {
196
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
197
1x
        return
198
1x
    }
199

200
    // Extract values from generated types
201
2x
    timezone := "UTC"
202
2x
    if req.Timezone != nil && *req.Timezone != "" {
203
1x
        timezone = *req.Timezone
204
1x
        // Validate timezone if provided
205
1x
        if !h.isValidTimezone(timezone) {
206
            HandleAppError(c, contextutils.ErrInvalidFormat)
207
            return
208
        }
209
    }
210

211
2x
    preferredLanguage := "italian"
212
2x
    if req.PreferredLanguage != nil && *req.PreferredLanguage != "" {
213
1x
        preferredLanguage = *req.PreferredLanguage
214
1x
    }
215

216
2x
    currentLevel := "A1"
217
2x
    if req.CurrentLevel != nil && *req.CurrentLevel != "" {
218
1x
        currentLevel = *req.CurrentLevel
219
1x
    }
220

221
2x
    email := ""
222
2x
    if req.Email != nil {
223
2x
        email = string(*req.Email)
224
2x
    }
225

226
    // Check if username already exists
227
2x
    existingUser, err := h.userService.GetUserByUsername(c.Request.Context(), req.Username)
228
2x
    if err != nil {
229
        h.logger.Error(c.Request.Context(), "Error checking existing username", err, nil)
230
        HandleAppError(c, contextutils.WrapError(err, "failed to check existing username"))
231
        return
232
    }
233
2x
    if existingUser != nil {
234
1x
        HandleAppError(c, contextutils.ErrRecordExists)
235
1x
        return
236
1x
    }
237

238
    // Check if email already exists (if provided)
239
1x
    if email != "" {
240
1x
        existingUser, err := h.userService.GetUserByEmail(c.Request.Context(), email)
241
1x
        if err != nil {
242
            h.logger.Error(c.Request.Context(), "Error checking existing email", err, nil)
243
            HandleAppError(c, contextutils.WrapError(err, "failed to check email uniqueness"))
244
            return
245
        }
246
1x
        if existingUser != nil {
247
            HandleAppError(c, contextutils.ErrRecordExists)
248
            return
249
        }
250
    }
251

252
    // Create user
253
1x
    user, err := h.userService.CreateUserWithEmailAndTimezone(
254
1x
        c.Request.Context(),
255
1x
        req.Username,
256
1x
        email,
257
1x
        timezone,
258
1x
        preferredLanguage,
259
1x
        currentLevel,
260
1x
    )
261
1x
    if err != nil {
262
        h.logger.Error(c.Request.Context(), "Error creating user", err, nil)
263
        HandleAppError(c, contextutils.WrapError(err, "failed to create user"))
264
        return
265
    }
266

267
    // Set password
268
1x
    err = h.userService.UpdateUserPassword(c.Request.Context(), user.ID, req.Password)
269
1x
    if err != nil {
270
        h.logger.Error(c.Request.Context(), "Error setting user password", err, nil)
271
        // Try to clean up the created user
272
        _ = h.userService.DeleteUser(c.Request.Context(), user.ID)
273
        HandleAppError(c, contextutils.WrapError(err, "failed to set user password"))
274
        return
275
    }
276

277
    // Return the created user profile
278
1x
    c.JSON(http.StatusCreated, gin.H{
279
1x
        "message": "User created successfully",
280
1x
        "user":    h.convertUserToProfileResponse(c.Request.Context(), user),
281
1x
    })
282
}
283

284
// UpdateUser handles PUT /userz/:id - update user details (admin or self)
285
1x
func (h *UserAdminHandler) UpdateUser(c *gin.Context) {
286
1x
    userIDStr := c.Param("id")
287
1x
    userID, err := strconv.Atoi(userIDStr)
288
1x
    if err != nil {
289
        HandleAppError(c, contextutils.ErrInvalidFormat)
290
        return
291
    }
292

293
    // Check if user exists
294
1x
    user, err := h.userService.GetUserByID(c.Request.Context(), userID)
295
1x
    if err != nil {
296
        h.logger.Error(c.Request.Context(), "Error retrieving user", err, nil)
297
        HandleAppError(c, contextutils.WrapError(err, "database error"))
298
        return
299
    }
300
1x
    if user == nil {
301
        HandleAppError(c, contextutils.ErrRecordNotFound)
302
        return
303
    }
304

305
    // Check authorization (admin or self) - skip for direct routes (testing)
306
1x
    if currentUserID, err := GetCurrentUserID(c); err == nil {
307
1x
        if err := RequireSelfOrAdmin(c.Request.Context(), h.userService, currentUserID, userID); err != nil {
308
            if contextutils.IsError(err, contextutils.ErrForbidden) {
309
                HandleAppError(c, contextutils.ErrForbidden)
310
                return
311
            }
312
            h.logger.Error(c.Request.Context(), "Error checking authorization", err, nil)
313
            HandleAppError(c, contextutils.WrapError(err, "failed to check authorization"))
314
            return
315
        }
316
    }
317

318
1x
    var req UserUpdateRequest
319
1x
    if err := c.ShouldBindJSON(&req); err != nil {
320
        HandleAppError(c, contextutils.NewAppErrorWithCause(
321
            contextutils.ErrorCodeInvalidInput,
322
            contextutils.SeverityWarn,
323
            "Invalid request data",
324
            "",
325
            err,
326
        ))
327
        return
328
    }
329

330
    // Validate timezone if provided
331
1x
    if req.Timezone != nil && *req.Timezone != "" && !h.isValidTimezone(*req.Timezone) {
332
        HandleAppError(c, contextutils.ErrInvalidFormat)
333
        return
334
    }
335

336
    // Use existing values if not provided in request
337
1x
    username := user.Username
338
1x
    if req.Username != nil && *req.Username != "" {
339
1x
        username = *req.Username
340
1x
    }
341

342
1x
    email := ""
343
1x
    if user.Email.Valid {
344
1x
        email = user.Email.String
345
1x
    }
346
1x
    if req.Email != nil {
347
1x
        email = string(*req.Email)
348
1x
    }
349

350
1x
    timezone := ""
351
1x
    if user.Timezone.Valid {
352
1x
        timezone = user.Timezone.String
353
1x
    }
354
1x
    if req.Timezone != nil && *req.Timezone != "" {
355
1x
        timezone = *req.Timezone
356
1x
    }
357

358
1x
    preferredLanguage := ""
359
1x
    if user.PreferredLanguage.Valid {
360
1x
        preferredLanguage = user.PreferredLanguage.String
361
1x
    }
362
1x
    if req.PreferredLanguage != nil && *req.PreferredLanguage != "" {
363
        preferredLanguage = *req.PreferredLanguage
364
    }
365

366
1x
    currentLevel := ""
367
1x
    if user.CurrentLevel.Valid {
368
1x
        currentLevel = user.CurrentLevel.String
369
1x
    }
370
1x
    if req.CurrentLevel != nil && *req.CurrentLevel != "" {
371
        currentLevel = *req.CurrentLevel
372
    }
373

374
    // Check if new username already exists (if changed)
375
1x
    if username != user.Username {
376
1x
        existingUser, err := h.userService.GetUserByUsername(c.Request.Context(), username)
377
1x
        if err != nil {
378
            h.logger.Error(c.Request.Context(), "Error checking existing username", err, nil)
379
            HandleAppError(c, contextutils.WrapError(err, "failed to check username uniqueness"))
380
            return
381
        }
382
1x
        if existingUser != nil {
383
            HandleAppError(c, contextutils.ErrRecordExists)
384
            return
385
        }
386
    }
387

388
    // Check if new email already exists (if changed)
389
1x
    if email != "" && user.Email.Valid && email != user.Email.String {
390
1x
        existingUser, err := h.userService.GetUserByEmail(c.Request.Context(), email)
391
1x
        if err != nil {
392
            h.logger.Error(c.Request.Context(), "Error checking existing email", err, nil)
393
            HandleAppError(c, contextutils.WrapError(err, "failed to check email uniqueness"))
394
            return
395
        }
396
1x
        if existingUser != nil {
397
            HandleAppError(c, contextutils.ErrRecordExists)
398
            return
399
        }
400
    }
401

402
    // Update user profile
403
1x
    err = h.userService.UpdateUserProfile(c.Request.Context(), userID, username, email, timezone)
404
1x
    if err != nil {
405
        h.logger.Error(c.Request.Context(), "Error updating user profile", err, nil)
406

407
        // Check if the error is due to user not found
408
        if errors.Is(err, contextutils.ErrRecordNotFound) {
409
            HandleAppError(c, contextutils.ErrRecordNotFound)
410
            return
411
        }
412

413
        HandleAppError(c, contextutils.WrapError(err, "failed to update user profile"))
414
        return
415
    }
416

417
    // Handle AI settings update if provided
418
1x
    needsAIUpdate := req.AiEnabled != nil || (req.AiProvider != nil && *req.AiProvider != "") || (req.AiModel != nil && *req.AiModel != "") || (req.ApiKey != nil && *req.ApiKey != "")
419
1x
    if needsAIUpdate {
420
        // Prepare AI settings
421
        aiSettings := &models.UserSettings{
422
            Language:  preferredLanguage,
423
            Level:     currentLevel,
424
            AIEnabled: req.AiEnabled != nil && *req.AiEnabled,
425
        }
426

427
        // Set AI provider and model
428
        if req.AiProvider != nil && *req.AiProvider != "" {
429
            aiSettings.AIProvider = *req.AiProvider
430
        } else if user.AIProvider.Valid {
431
            aiSettings.AIProvider = user.AIProvider.String
432
        }
433

434
        if req.AiModel != nil && *req.AiModel != "" {
435
            aiSettings.AIModel = *req.AiModel
436
        } else if user.AIModel.Valid {
437
            aiSettings.AIModel = user.AIModel.String
438
        }
439

440
        // Set API key if provided
441
        if req.ApiKey != nil && *req.ApiKey != "" {
442
            aiSettings.AIAPIKey = *req.ApiKey
443
        }
444

445
        // Update AI settings
446
        err = h.userService.UpdateUserSettings(c.Request.Context(), userID, aiSettings)
447
        if err != nil {
448
            h.logger.Error(c.Request.Context(), "Error updating user AI settings", err, nil)
449

450
            // Check if the error is due to user not found
451
            if errors.Is(err, contextutils.ErrRecordNotFound) {
452
                HandleAppError(c, contextutils.ErrRecordNotFound)
453
                return
454
            }
455

456
            HandleAppError(c, contextutils.WrapError(err, "failed to update AI settings"))
457
            return
458
        }
459
    }
460

461
    // Handle role updates if provided
462
1x
    if req.SelectedRoles != nil {
463
        // Get current user roles
464
        currentRoles, err := h.userService.GetUserRoles(c.Request.Context(), userID)
465
        if err != nil {
466
            h.logger.Error(c.Request.Context(), "Error getting current user roles", err, nil)
467
            HandleAppError(c, contextutils.WrapError(err, "failed to get current user roles"))
468
            return
469
        }
470

471
        // Get all available roles
472
        allRoles, err := h.userService.GetAllRoles(c.Request.Context())
473
        if err != nil {
474
            h.logger.Error(c.Request.Context(), "Error getting all roles", err, nil)
475
            HandleAppError(c, contextutils.WrapError(err, "failed to get available roles"))
476
            return
477
        }
478

479
        // Create maps for efficient lookup
480
        currentRoleNames := make(map[string]bool)
481
        for _, role := range currentRoles {
482
            currentRoleNames[role.Name] = true
483
        }
484

485
        requestedRoleNames := make(map[string]bool)
486
        for _, roleName := range *req.SelectedRoles {
487
            requestedRoleNames[roleName] = true
488
        }
489

490
        // Find roles to add and remove
491
        for _, roleName := range *req.SelectedRoles {
492
            if !currentRoleNames[roleName] {
493
                // Find role by name
494
                var roleToAdd *models.Role
495
                for _, role := range allRoles {
496
                    if role.Name == roleName {
497
                        roleToAdd = &role
498
                        break
499
                    }
500
                }
501
                if roleToAdd != nil {
502
                    err = h.userService.AssignRole(c.Request.Context(), userID, roleToAdd.ID)
503
                    if err != nil {
504
                        h.logger.Error(c.Request.Context(), "Error assigning role to user", err, map[string]interface{}{
505
                            "user_id":   userID,
506
                            "role_id":   roleToAdd.ID,
507
                            "role_name": roleName,
508
                        })
509
                        HandleAppError(c, contextutils.WrapError(err, "failed to assign role"))
510
                        return
511
                    }
512
                }
513
            }
514
        }
515

516
        // Remove roles that are no longer selected
517
        for _, role := range currentRoles {
518
            if !requestedRoleNames[role.Name] {
519
                err = h.userService.RemoveRole(c.Request.Context(), userID, role.ID)
520
                if err != nil {
521
                    h.logger.Error(c.Request.Context(), "Error removing role from user", err, map[string]interface{}{
522
                        "user_id":   userID,
523
                        "role_id":   role.ID,
524
                        "role_name": role.Name,
525
                    })
526
                    HandleAppError(c, contextutils.WrapError(err, "failed to remove role"))
527
                    return
528
                }
529
            }
530
        }
531
    }
532

533
    // Get updated user
534
1x
    updatedUser, err := h.userService.GetUserByID(c.Request.Context(), userID)
535
1x
    if err != nil {
536
        h.logger.Error(c.Request.Context(), "Error retrieving updated user", err, nil)
537
        HandleAppError(c, contextutils.WrapError(err, "failed to retrieve updated user"))
538
        return
539
    }
540

541
1x
    c.JSON(http.StatusOK, gin.H{
542
1x
        "message": "User updated successfully",
543
1x
        "user":    h.convertUserToProfileResponse(c.Request.Context(), updatedUser),
544
1x
    })
545
}
546

547
// DeleteUser handles DELETE /userz/:id - delete user (admin only)
548
1x
func (h *UserAdminHandler) DeleteUser(c *gin.Context) {
549
1x
    userIDStr := c.Param("id")
550
1x
    userID, err := strconv.Atoi(userIDStr)
551
1x
    if err != nil {
552
        HandleAppError(c, contextutils.ErrInvalidFormat)
553
        return
554
    }
555

556
    // Check if user exists
557
1x
    user, err := h.userService.GetUserByID(c.Request.Context(), userID)
558
1x
    if err != nil {
559
        h.logger.Error(c.Request.Context(), "Error retrieving user", err, nil)
560
        HandleAppError(c, contextutils.WrapError(err, "database error"))
561
        return
562
    }
563
1x
    if user == nil {
564
        HandleAppError(c, contextutils.ErrRecordNotFound)
565
        return
566
    }
567

568
    // Delete user
569
1x
    err = h.userService.DeleteUser(c.Request.Context(), userID)
570
1x
    if err != nil {
571
        h.logger.Error(c.Request.Context(), "Error deleting user", err, nil)
572
        HandleAppError(c, contextutils.WrapError(err, "failed to delete user"))
573
        return
574
    }
575

576
1x
    c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"})
577
}
578

579
// ResetUserPassword handles POST /userz/:id/reset-password - reset user password (admin only)
580
1x
func (h *UserAdminHandler) ResetUserPassword(c *gin.Context) {
581
1x
    userIDStr := c.Param("id")
582
1x
    userID, err := strconv.Atoi(userIDStr)
583
1x
    if err != nil {
584
        HandleAppError(c, contextutils.ErrInvalidFormat)
585
        return
586
    }
587

588
    // Check if user exists
589
1x
    user, err := h.userService.GetUserByID(c.Request.Context(), userID)
590
1x
    if err != nil {
591
        h.logger.Error(c.Request.Context(), "Error retrieving user", err, map[string]interface{}{"user_id": userID})
592
        HandleAppError(c, contextutils.WrapError(err, "database error"))
593
        return
594
    }
595
1x
    if user == nil {
596
        h.logger.Warn(c.Request.Context(), "User not found for password reset", map[string]interface{}{"user_id": userID})
597
        HandleAppError(c, contextutils.ErrRecordNotFound)
598
        return
599
    }
600

601
1x
    var req PasswordResetRequest
602
1x
    if err := c.ShouldBindJSON(&req); err != nil {
603
        h.logger.Error(c.Request.Context(), "Invalid request data for password reset", err, map[string]interface{}{"user_id": userID})
604
        HandleAppError(c, contextutils.NewAppErrorWithCause(
605
            contextutils.ErrorCodeInvalidInput,
606
            contextutils.SeverityWarn,
607
            "Invalid request data",
608
            "",
609
            err,
610
        ))
611
        return
612
    }
613

614
    // Validate password
615
1x
    if req.NewPassword == "" {
616
        HandleAppError(c, contextutils.ErrMissingRequired)
617
        return
618
    }
619

620
    // Update password
621
1x
    err = h.userService.UpdateUserPassword(c.Request.Context(), userID, req.NewPassword)
622
1x
    if err != nil {
623
        h.logger.Error(c.Request.Context(), "Error updating user password", err, map[string]interface{}{"user_id": userID})
624
        HandleAppError(c, contextutils.WrapError(err, "failed to update password"))
625
        return
626
    }
627

628
1x
    h.logger.Info(c.Request.Context(), "Password reset successful", map[string]interface{}{"user_id": userID, "username": user.Username})
629
1x
    c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"})
630
}
631

632
// UpdateCurrentUserProfile handles PUT /userz/profile - update current user profile
633
2x
func (h *UserAdminHandler) UpdateCurrentUserProfile(c *gin.Context) {
634
2x
    // Get user ID from context/session
635
2x
    userID, err := GetCurrentUserID(c)
636
2x
    if err != nil {
637
        HandleAppError(c, contextutils.ErrUnauthorized)
638
        return
639
    }
640

641
2x
    var req UserUpdateRequest
642
2x
    if err := c.ShouldBindJSON(&req); err != nil {
643
        HandleAppError(c, contextutils.NewAppErrorWithCause(
644
            contextutils.ErrorCodeInvalidInput,
645
            contextutils.SeverityWarn,
646
            "Invalid request data",
647
            "",
648
            err,
649
        ))
650
        return
651
    }
652

653
    // Validate timezone if provided
654
2x
    if req.Timezone != nil && *req.Timezone != "" && !h.isValidTimezone(*req.Timezone) {
655
        HandleAppError(c, contextutils.ErrInvalidFormat)
656
        return
657
    }
658

659
    // Email validation is handled automatically by openapi_types.Email
660

661
    // Get current user
662
2x
    user, err := h.userService.GetUserByID(c.Request.Context(), userID)
663
2x
    if err != nil {
664
        h.logger.Error(c.Request.Context(), "Error retrieving user", err, nil)
665
        HandleAppError(c, contextutils.WrapError(err, "database error"))
666
        return
667
    }
668
2x
    if user == nil {
669
        HandleAppError(c, contextutils.ErrRecordNotFound)
670
        return
671
    }
672

673
    // Check authorization (self-only for this endpoint)
674
2x
    if err := RequireSelfOrAdmin(c.Request.Context(), h.userService, userID, userID); err != nil {
675
        if contextutils.IsError(err, contextutils.ErrForbidden) {
676
            HandleAppError(c, contextutils.ErrForbidden)
677
            return
678
        }
679
        h.logger.Error(c.Request.Context(), "Error checking authorization", err, nil)
680
        HandleAppError(c, contextutils.WrapError(err, "failed to check authorization"))
681
        return
682
    }
683

684
    // Use existing values if not provided in request
685
2x
    username := user.Username
686
2x
    if req.Username != nil && *req.Username != "" {
687
2x
        username = *req.Username
688
2x
    }
689

690
2x
    email := ""
691
2x
    if user.Email.Valid {
692
        email = user.Email.String
693
    }
694
2x
    if req.Email != nil {
695
1x
        email = string(*req.Email)
696
1x
    }
697

698
2x
    timezone := ""
699
2x
    if user.Timezone.Valid {
700
2x
        timezone = user.Timezone.String
701
2x
    }
702
2x
    if req.Timezone != nil && *req.Timezone != "" {
703
2x
        timezone = *req.Timezone
704
2x
    }
705

706
    // Check if new username already exists (if changed)
707
2x
    if username != user.Username {
708
        existingUser, err := h.userService.GetUserByUsername(c.Request.Context(), username)
709
        if err != nil {
710
            h.logger.Error(c.Request.Context(), "Error checking existing username", err, nil)
711
            HandleAppError(c, contextutils.WrapError(err, "failed to check username uniqueness"))
712
            return
713
        }
714
        if existingUser != nil {
715
            HandleAppError(c, contextutils.ErrRecordExists)
716
            return
717
        }
718
    }
719

720
    // Check if new email already exists (if changed)
721
2x
    if email != "" && user.Email.Valid && email != user.Email.String {
722
        existingUser, err := h.userService.GetUserByEmail(c.Request.Context(), email)
723
        if err != nil {
724
            h.logger.Error(c.Request.Context(), "Error checking existing email", err, nil)
725
            HandleAppError(c, contextutils.WrapError(err, "failed to check email uniqueness"))
726
            return
727
        }
728
        if existingUser != nil {
729
            HandleAppError(c, contextutils.ErrRecordExists)
730
            return
731
        }
732
    }
733

734
    // Use existing AI values if not provided in request
735
2x
    preferredLanguage := ""
736
2x
    if user.PreferredLanguage.Valid {
737
2x
        preferredLanguage = user.PreferredLanguage.String
738
2x
    }
739
2x
    if req.PreferredLanguage != nil && *req.PreferredLanguage != "" {
740
2x
        preferredLanguage = *req.PreferredLanguage
741
2x
    }
742

743
2x
    currentLevel := ""
744
2x
    if user.CurrentLevel.Valid {
745
2x
        currentLevel = user.CurrentLevel.String
746
2x
    }
747
2x
    if req.CurrentLevel != nil && *req.CurrentLevel != "" {
748
2x
        currentLevel = *req.CurrentLevel
749
2x
    }
750

751
    // Update user profile
752
2x
    err = h.userService.UpdateUserProfile(c.Request.Context(), userID, username, email, timezone)
753
2x
    if err != nil {
754
        h.logger.Error(c.Request.Context(), "Error updating user profile", err, nil)
755
        HandleAppError(c, contextutils.WrapError(err, "failed to update user profile"))
756
        return
757
    }
758

759
    // Handle AI settings update if provided
760
2x
    needsAIUpdate := req.AiEnabled != nil || (req.AiProvider != nil && *req.AiProvider != "") || (req.AiModel != nil && *req.AiModel != "") || (req.PreferredLanguage != nil && *req.PreferredLanguage != "") || (req.CurrentLevel != nil && *req.CurrentLevel != "") || (req.ApiKey != nil && *req.ApiKey != "")
761
2x

762
2x
    if needsAIUpdate {
763
2x
        aiSettings := &models.UserSettings{
764
2x
            Language:  preferredLanguage,
765
2x
            Level:     currentLevel,
766
2x
            AIEnabled: req.AiEnabled != nil && *req.AiEnabled,
767
2x
        }
768
2x

769
2x
        if req.AiProvider != nil && *req.AiProvider != "" {
770
1x
            aiSettings.AIProvider = *req.AiProvider
771
1x
        } else if user.AIProvider.Valid {
772
            aiSettings.AIProvider = user.AIProvider.String
773
        }
774

775
2x
        if req.AiModel != nil && *req.AiModel != "" {
776
1x
            aiSettings.AIModel = *req.AiModel
777
1x
        } else if user.AIModel.Valid {
778
            aiSettings.AIModel = user.AIModel.String
779
        }
780

781
2x
        if req.ApiKey != nil && *req.ApiKey != "" {
782
1x
            aiSettings.AIAPIKey = *req.ApiKey
783
1x
        }
784

785
2x
        err = h.userService.UpdateUserSettings(c.Request.Context(), userID, aiSettings)
786
2x
        if err != nil {
787
            h.logger.Error(c.Request.Context(), "Error updating user AI settings", err, nil)
788
            HandleAppError(c, contextutils.WrapError(err, "failed to update AI settings"))
789
            return
790
        }
791
    }
792

793
    // Get updated user
794
2x
    updatedUser, err := h.userService.GetUserByID(c.Request.Context(), userID)
795
2x
    if err != nil {
796
        h.logger.Error(c.Request.Context(), "Error retrieving updated user", err, nil)
797
        HandleAppError(c, contextutils.WrapError(err, "failed to retrieve updated profile"))
798
        return
799
    }
800

801
2x
    c.JSON(http.StatusOK, gin.H{
802
2x
        "message": "Profile updated successfully",
803
2x
        "user":    h.convertUserToProfileResponse(c.Request.Context(), updatedUser),
804
2x
    })
805
}
806

807
// isUserPaused checks if a user is paused by checking the worker_settings table
808
7x
func (h *UserAdminHandler) isUserPaused(ctx context.Context, userID int) bool {
809
7x
    query := `SELECT setting_value FROM worker_settings WHERE setting_key = $1`
810
7x
    var value string
811
7x
    settingKey := fmt.Sprintf("user_pause_%d", userID)
812
7x

813
7x
    err := h.userService.GetDB().QueryRowContext(ctx, query, settingKey).Scan(&value)
814
7x
    if err != nil {
815
7x
        // If no setting exists, user is not paused
816
7x
        if errors.Is(err, sql.ErrNoRows) {
817
7x
            return false
818
7x
        }
819
        // Log error but don't fail - default to not paused
820
        h.logger.Warn(ctx, "Failed to check user pause status", map[string]interface{}{
821
            "user_id": userID,
822
            "error":   err.Error(),
823
        })
824
        return false
825
    }
826

827
    return value == "true"
828
}
829

830
// Helper functions
831

832
// convertUserToProfileResponse converts a User model to ProfileResponse
833
7x
func (h *UserAdminHandler) convertUserToProfileResponse(ctx context.Context, user *models.User) ProfileResponse {
834
7x
    // Get user roles
835
7x
    roles, err := h.userService.GetUserRoles(ctx, user.ID)
836
7x
    if err != nil {
837
        // Log error but don't fail the response
838
        h.logger.Warn(ctx, "Failed to get user roles", map[string]interface{}{
839
            "user_id": user.ID,
840
            "error":   err.Error(),
841
        })
842
        roles = []models.Role{}
843
    }
844

845
7x
    return ProfileResponse{
846
7x
        ID:                user.ID,
847
7x
        Username:          user.Username,
848
7x
        Email:             nullStringToPointer(user.Email),
849
7x
        Timezone:          nullStringToPointer(user.Timezone),
850
7x
        LastActive:        nullTimeToPointer(user.LastActive),
851
7x
        PreferredLanguage: nullStringToPointer(user.PreferredLanguage),
852
7x
        CurrentLevel:      nullStringToPointer(user.CurrentLevel),
853
7x
        CreatedAt:         user.CreatedAt,
854
7x
        UpdatedAt:         user.UpdatedAt,
855
7x
        AIEnabled:         user.AIEnabled.Valid && user.AIEnabled.Bool,
856
7x
        AIProvider:        nullStringToPointer(user.AIProvider),
857
7x
        AIModel:           nullStringToPointer(user.AIModel),
858
7x
        Roles:             roles,
859
7x
        IsPaused:          h.isUserPaused(ctx, user.ID),
860
7x
    }
861
}
862

863
// isValidTimezone checks if a timezone string is valid
864
4x
func (h *UserAdminHandler) isValidTimezone(tz string) bool {
865
4x
    // Common timezone validation - check if it can be loaded
866
4x
    _, err := time.LoadLocation(tz)
867
4x
    if err != nil {
868
        // Also allow UTC as fallback
869
        return strings.ToUpper(tz) == "UTC"
870
    }
871
4x
    return true
872
}
873

874
// Helper function to convert sql.NullString to *string (if not already available)
875
42x
func nullStringToPointer(ns sql.NullString) *string {
876
42x
    if ns.Valid {
877
31x
        return &ns.String
878
31x
    }
879
11x
    return nil
880
}
881

882
// Helper function to convert sql.NullTime to *time.Time (if not already available)
883
7x
func nullTimeToPointer(nt sql.NullTime) *time.Time {
884
7x
    if nt.Valid {
885
7x
        return &nt.Time
886
7x
    }
887
    return nil
888
}
889


			
quizapp internal handlers worker_admin_handler.go
67.0%
Statements
61/91
1
package handlers
2

3
import (
4
    "embed"
5
    "encoding/json"
6
    "fmt"
7
    "net/http"
8
    "strings"
9

10
    "quizapp/internal/observability"
11
    contextutils "quizapp/internal/utils"
12

13
    "github.com/gin-gonic/gin"
14
    "go.opentelemetry.io/otel/attribute"
15
)
16

17
//go:embed data/verb-conjugations
18
var verbConjugationFS embed.FS
19

20
// VerbConjugationHandler handles verb conjugation related HTTP requests
21
type VerbConjugationHandler struct {
22
    logger *observability.Logger
23
}
24

25
// NewVerbConjugationHandler creates a new VerbConjugationHandler instance
26
14x
func NewVerbConjugationHandler(logger *observability.Logger) *VerbConjugationHandler {
27
14x
    return &VerbConjugationHandler{
28
14x
        logger: logger,
29
14x
    }
30
14x
}
31

32
// VerbConjugationData represents the complete verb conjugation data for a language
33
type VerbConjugationData struct {
34
    Language     string            `json:"language"`
35
    LanguageName string            `json:"languageName"`
36
    Verbs        []VerbConjugation `json:"verbs"`
37
}
38

39
// VerbConjugation represents a single verb with its conjugations across all tenses
40
type VerbConjugation struct {
41
    Language     string  `json:"language"`
42
    LanguageName string  `json:"languageName"`
43
    Infinitive   string  `json:"infinitive"`
44
    InfinitiveEn string  `json:"infinitiveEn"`
45
    Slug         string  `json:"slug,omitempty"` // Optional ASCII slug for filename when infinitive has Unicode combining characters
46
    Category     string  `json:"category"`
47
    Tenses       []Tense `json:"tenses"`
48
}
49

50
// Tense represents a grammatical tense with its conjugations and description
51
type Tense struct {
52
    TenseID      string        `json:"tenseId"`
53
    TenseName    string        `json:"tenseName"`
54
    TenseNameEn  string        `json:"tenseNameEn"`
55
    Description  string        `json:"description"`
56
    Conjugations []Conjugation `json:"conjugations"`
57
}
58

59
// Conjugation represents a single conjugated form with example sentence
60
type Conjugation struct {
61
    Pronoun           string `json:"pronoun"`
62
    Form              string `json:"form"`
63
    ExampleSentence   string `json:"exampleSentence"`
64
    ExampleSentenceEn string `json:"exampleSentenceEn"`
65
}
66

67
// VerbConjugationInfo represents metadata about the verb conjugation section
68
type VerbConjugationInfo struct {
69
    ID          string `json:"id"`
70
    Name        string `json:"name"`
71
    Emoji       string `json:"emoji"`
72
    Description string `json:"description"`
73
}
74

75
// GetVerbConjugationInfo returns metadata about verb conjugations
76
1x
func (h *VerbConjugationHandler) GetVerbConjugationInfo(c *gin.Context) {
77
1x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_verb_conjugation_info")
78
1x
    defer observability.FinishSpan(span, nil)
79
1x

80
1x
    data, err := verbConjugationFS.ReadFile("data/verb-conjugations/info.json")
81
1x
    if err != nil {
82
        h.logger.Error(c.Request.Context(), "Failed to read verb conjugation info", err)
83
        HandleAppError(c, contextutils.WrapError(err, "failed to read verb conjugation info"))
84
        return
85
    }
86

87
1x
    var info VerbConjugationInfo
88
1x
    if err := json.Unmarshal(data, &info); err != nil {
89
        h.logger.Error(c.Request.Context(), "Failed to parse verb conjugation info", err)
90
        HandleAppError(c, contextutils.WrapError(err, "failed to parse verb conjugation info"))
91
        return
92
    }
93

94
1x
    c.JSON(http.StatusOK, info)
95
}
96

97
// GetVerbConjugations returns all verbs for a specific language
98
10x
func (h *VerbConjugationHandler) GetVerbConjugations(c *gin.Context) {
99
10x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_verb_conjugations")
100
10x
    defer observability.FinishSpan(span, nil)
101
10x

102
10x
    languageCode := c.Param("language")
103
10x
    if languageCode == "" {
104
        HandleAppError(c, contextutils.ErrMissingRequired)
105
        return
106
    }
107

108
10x
    span.SetAttributes(attribute.String("language", languageCode))
109
10x

110
10x
    // Read all verb files in the language directory
111
10x
    languageDir := fmt.Sprintf("data/verb-conjugations/%s", languageCode)
112
10x
    entries, err := verbConjugationFS.ReadDir(languageDir)
113
10x
    if err != nil {
114
1x
        // Check if it's a directory not found error
115
1x
        if strings.Contains(err.Error(), "file does not exist") || strings.Contains(err.Error(), "no such file") || strings.Contains(err.Error(), "not found") {
116
1x
            HandleAppError(c, contextutils.ErrRecordNotFound)
117
1x
            return
118
1x
        }
119
        h.logger.Error(c.Request.Context(), "Failed to read verb conjugation directory", err, map[string]interface{}{
120
            "language":  languageCode,
121
            "directory": languageDir,
122
        })
123
        HandleAppError(c, contextutils.WrapError(err, "failed to read verb conjugation directory"))
124
        return
125
    }
126

127
9x
    var verbs []VerbConjugation
128
9x
    var languageName string
129
9x
    var language string
130
9x

131
9x
    // Read each verb file
132
9x
    for _, entry := range entries {
133
76x
        if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
134
76x
            filename := fmt.Sprintf("%s/%s", languageDir, entry.Name())
135
76x
            data, err := verbConjugationFS.ReadFile(filename)
136
76x
            if err != nil {
137
                h.logger.Error(c.Request.Context(), "Failed to read verb file", err, map[string]interface{}{
138
                    "language": languageCode,
139
                    "filename": filename,
140
                })
141
                HandleAppError(c, contextutils.WrapError(err, "failed to read verb file"))
142
                return
143
            }
144

145
76x
            var verb VerbConjugation
146
76x
            if err := json.Unmarshal(data, &verb); err != nil {
147
                h.logger.Error(c.Request.Context(), "Failed to parse verb file", err, map[string]interface{}{
148
                    "language": languageCode,
149
                    "filename": filename,
150
                })
151
                HandleAppError(c, contextutils.WrapError(err, "failed to parse verb file"))
152
                return
153
            }
154

155
            // Set language metadata from first verb (all verbs in a directory should have the same language)
156
76x
            if languageName == "" {
157
9x
                languageName = verb.LanguageName
158
9x
                language = verb.Language
159
9x
            }
160

161
76x
            verbs = append(verbs, verb)
162
        }
163
    }
164

165
9x
    if len(verbs) == 0 {
166
        HandleAppError(c, contextutils.ErrRecordNotFound)
167
        return
168
    }
169

170
9x
    verbData := VerbConjugationData{
171
9x
        Language:     language,
172
9x
        LanguageName: languageName,
173
9x
        Verbs:        verbs,
174
9x
    }
175
9x

176
9x
    c.JSON(http.StatusOK, verbData)
177
}
178

179
// GetVerbConjugation returns a specific verb's conjugations for a language
180
4x
func (h *VerbConjugationHandler) GetVerbConjugation(c *gin.Context) {
181
4x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_verb_conjugation")
182
4x
    defer observability.FinishSpan(span, nil)
183
4x

184
4x
    languageCode := c.Param("language")
185
4x
    verbInfinitive := c.Param("verb")
186
4x

187
4x
    if languageCode == "" || verbInfinitive == "" {
188
        HandleAppError(c, contextutils.ErrMissingRequired)
189
        return
190
    }
191

192
4x
    span.SetAttributes(attribute.String("language", languageCode))
193
4x
    span.SetAttributes(attribute.String("verb", verbInfinitive))
194
4x

195
4x
    // Read the specific verb file
196
4x
    filename := fmt.Sprintf("data/verb-conjugations/%s/%s.json", languageCode, verbInfinitive)
197
4x
    data, err := verbConjugationFS.ReadFile(filename)
198
4x
    if err != nil {
199
2x
        // Check if it's a file not found error
200
2x
        if strings.Contains(err.Error(), "file does not exist") || strings.Contains(err.Error(), "no such file") || strings.Contains(err.Error(), "not found") {
201
2x
            HandleAppError(c, contextutils.ErrRecordNotFound)
202
2x
            return
203
2x
        }
204
        h.logger.Error(c.Request.Context(), "Failed to read verb file", err, map[string]interface{}{
205
            "language": languageCode,
206
            "verb":     verbInfinitive,
207
            "filename": filename,
208
        })
209
        HandleAppError(c, contextutils.WrapError(err, "failed to read verb file"))
210
        return
211
    }
212

213
2x
    var verb VerbConjugation
214
2x
    if err := json.Unmarshal(data, &verb); err != nil {
215
        h.logger.Error(c.Request.Context(), "Failed to parse verb file", err, map[string]interface{}{
216
            "language": languageCode,
217
            "verb":     verbInfinitive,
218
        })
219
        HandleAppError(c, contextutils.WrapError(err, "failed to parse verb file"))
220
        return
221
    }
222

223
2x
    c.JSON(http.StatusOK, verb)
224
}
225

226
// GetAvailableLanguages returns the list of available languages for verb conjugations
227
1x
func (h *VerbConjugationHandler) GetAvailableLanguages(c *gin.Context) {
228
1x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_available_languages")
229
1x
    defer observability.FinishSpan(span, nil)
230
1x

231
1x
    // Read all entries in the verb-conjugations directory
232
1x
    entries, err := verbConjugationFS.ReadDir("data/verb-conjugations")
233
1x
    if err != nil {
234
        h.logger.Error(c.Request.Context(), "Failed to read verb conjugation directory", err)
235
        HandleAppError(c, contextutils.WrapError(err, "failed to read verb conjugation directory"))
236
        return
237
    }
238

239
1x
    var languages []string
240
1x
    for _, entry := range entries {
241
9x
        // Only include directories (language folders), skip files like info.json
242
9x
        if entry.IsDir() {
243
8x
            languages = append(languages, entry.Name())
244
8x
        }
245
    }
246

247
1x
    c.JSON(http.StatusOK, languages)
248
}
249


			
quizapp internal handlers worker_admin_handler.go
59.4%
Statements
63/106
1
package handlers
2

3
import (
4
    "context"
5
    "fmt"
6
    "html/template"
7
    "net/http"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/config"
12
    "quizapp/internal/models"
13
    "quizapp/internal/observability"
14
    "quizapp/internal/services"
15
    contextutils "quizapp/internal/utils"
16

17
    "github.com/gin-gonic/gin"
18
    "go.opentelemetry.io/otel/attribute"
19
)
20

21
// WordOfTheDayHandler handles word of the day HTTP requests
22
type WordOfTheDayHandler struct {
23
    userService         services.UserServiceInterface
24
    wordOfTheDayService services.WordOfTheDayServiceInterface
25
    cfg                 *config.Config
26
    logger              *observability.Logger
27
}
28

29
// NewWordOfTheDayHandler creates a new WordOfTheDayHandler
30
func NewWordOfTheDayHandler(
31
    userService services.UserServiceInterface,
32
    wordOfTheDayService services.WordOfTheDayServiceInterface,
33
    cfg *config.Config,
34
    logger *observability.Logger,
35
14x
) *WordOfTheDayHandler {
36
14x
    return &WordOfTheDayHandler{
37
14x
        userService:         userService,
38
14x
        wordOfTheDayService: wordOfTheDayService,
39
14x
        cfg:                 cfg,
40
14x
        logger:              logger,
41
14x
    }
42
14x
}
43

44
// ParseDateInUserTimezone parses a date string in the user's timezone
45
9x
func (h *WordOfTheDayHandler) ParseDateInUserTimezone(ctx context.Context, userID int, dateStr string) (time.Time, string, error) {
46
9x
    // Delegate to shared util with injected user lookup
47
9x
    return contextutils.ParseDateInUserTimezone(ctx, userID, dateStr, h.userService.GetUserByID)
48
9x
}
49

50
// GetWordOfTheDay handles GET /v1/word-of-day/:date
51
3x
func (h *WordOfTheDayHandler) GetWordOfTheDay(c *gin.Context) {
52
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_word_of_the_day")
53
3x
    defer observability.FinishSpan(span, nil)
54
3x

55
3x
    userID, exists := GetUserIDFromSession(c)
56
3x
    if !exists {
57
        HandleAppError(c, contextutils.ErrUnauthorized)
58
        return
59
    }
60

61
    // Parse date parameter
62
3x
    dateStr := c.Param("date")
63
3x
    if dateStr == "" {
64
        HandleAppError(c, contextutils.ErrMissingRequired)
65
        return
66
    }
67

68
    // Parse date in user's timezone
69
3x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
70
3x
    if err != nil {
71
        if strings.Contains(err.Error(), "invalid date format") {
72
            HandleAppError(c, contextutils.ErrInvalidFormat)
73
            return
74
        }
75
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
76
        return
77
    }
78

79
3x
    span.SetAttributes(
80
3x
        observability.AttributeUserID(userID),
81
3x
        attribute.String("date", dateStr),
82
3x
        attribute.String("timezone", timezone),
83
3x
    )
84
3x

85
3x
    // Get word of the day
86
3x
    word, err := h.wordOfTheDayService.GetWordOfTheDay(ctx, userID, date)
87
3x
    if err != nil {
88
3x
        h.logger.Error(ctx, "Failed to get word of the day", err, map[string]interface{}{
89
3x
            "user_id": userID,
90
3x
            "date":    dateStr,
91
3x
        })
92
3x
        HandleAppError(c, contextutils.WrapError(err, "failed to get word of the day"))
93
3x
        return
94
3x
    }
95

96
    c.JSON(http.StatusOK, word)
97
}
98

99
// GetWordOfTheDayToday handles GET /v1/word-of-day
100
// It resolves "today" in the user's timezone and returns that day's word
101
2x
func (h *WordOfTheDayHandler) GetWordOfTheDayToday(c *gin.Context) {
102
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_word_of_the_day_today")
103
2x
    defer observability.FinishSpan(span, nil)
104
2x

105
2x
    userID, exists := GetUserIDFromSession(c)
106
2x
    if !exists {
107
        HandleAppError(c, contextutils.ErrUnauthorized)
108
        return
109
    }
110

111
    // Determine today's date string and parse it in user's timezone
112
2x
    todayStr := time.Now().Format("2006-01-02")
113
2x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, todayStr)
114
2x
    if err != nil {
115
        HandleAppError(c, contextutils.WrapError(err, "failed to resolve today's date"))
116
        return
117
    }
118

119
2x
    span.SetAttributes(
120
2x
        observability.AttributeUserID(userID),
121
2x
        attribute.String("date", todayStr),
122
2x
        attribute.String("timezone", timezone),
123
2x
    )
124
2x

125
2x
    // Get word of the day
126
2x
    word, err := h.wordOfTheDayService.GetWordOfTheDay(ctx, userID, date)
127
2x
    if err != nil {
128
2x
        h.logger.Error(ctx, "Failed to get today's word of the day", err, map[string]interface{}{
129
2x
            "user_id": userID,
130
2x
            "date":    todayStr,
131
2x
        })
132
2x
        HandleAppError(c, contextutils.WrapError(err, "failed to get word of the day"))
133
2x
        return
134
2x
    }
135

136
    c.JSON(http.StatusOK, word)
137
}
138

139
// GetWordOfTheDayEmbed handles GET /v1/word-of-day/:date/embed
140
// This endpoint returns HTML for embedding in an iframe. Requires an authenticated session.
141
2x
func (h *WordOfTheDayHandler) GetWordOfTheDayEmbed(c *gin.Context) {
142
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_word_of_the_day_embed")
143
2x
    defer observability.FinishSpan(span, nil)
144
2x

145
2x
    // Determine user via session; no query parameters are supported
146
2x
    userID, exists := GetUserIDFromSession(c)
147
2x
    if !exists {
148
        c.Data(http.StatusUnauthorized, "text/html; charset=utf-8", []byte("Unauthorized"))
149
        return
150
    }
151

152
    // Resolve date parameter from path, query, or default to today's date
153
2x
    dateStr := c.Param("date")
154
2x
    if dateStr == "" {
155
1x
        dateStr = c.Query("date")
156
1x
    }
157
2x
    if dateStr == "" {
158
1x
        dateStr = time.Now().Format("2006-01-02")
159
1x
    }
160

161
    // Parse date in user's timezone
162
2x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
163
2x
    if err != nil {
164
        c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte("Invalid date format"))
165
        return
166
    }
167

168
2x
    span.SetAttributes(
169
2x
        observability.AttributeUserID(userID),
170
2x
        attribute.String("date", dateStr),
171
2x
        attribute.String("timezone", timezone),
172
2x
    )
173
2x

174
2x
    // Get word of the day
175
2x
    word, err := h.wordOfTheDayService.GetWordOfTheDay(ctx, userID, date)
176
2x
    if err != nil {
177
2x
        h.logger.Error(ctx, "Failed to get word of the day for embed", err, map[string]interface{}{
178
2x
            "user_id": userID,
179
2x
            "date":    dateStr,
180
2x
        })
181
2x
        c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte("Failed to load word of the day"))
182
2x
        return
183
2x
    }
184

185
    // Render HTML template
186
    html := h.renderEmbedHTML(word)
187
    c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
188
}
189

190
// GetWordOfTheDayHistory handles GET /v1/word-of-day/history
191
1x
func (h *WordOfTheDayHandler) GetWordOfTheDayHistory(c *gin.Context) {
192
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_word_of_the_day_history")
193
1x
    defer observability.FinishSpan(span, nil)
194
1x

195
1x
    userID, exists := GetUserIDFromSession(c)
196
1x
    if !exists {
197
        HandleAppError(c, contextutils.ErrUnauthorized)
198
        return
199
    }
200

201
    // Parse date range parameters
202
1x
    startDateStr := c.Query("start_date")
203
1x
    endDateStr := c.Query("end_date")
204
1x

205
1x
    if startDateStr == "" || endDateStr == "" {
206
        HandleAppError(c, contextutils.ErrMissingRequired)
207
        return
208
    }
209

210
    // Parse dates in user's timezone
211
1x
    startDate, _, err := h.ParseDateInUserTimezone(ctx, userID, startDateStr)
212
1x
    if err != nil {
213
        HandleAppError(c, contextutils.WrapError(err, "invalid start_date"))
214
        return
215
    }
216

217
1x
    endDate, _, err := h.ParseDateInUserTimezone(ctx, userID, endDateStr)
218
1x
    if err != nil {
219
        HandleAppError(c, contextutils.WrapError(err, "invalid end_date"))
220
        return
221
    }
222

223
1x
    span.SetAttributes(
224
1x
        observability.AttributeUserID(userID),
225
1x
        attribute.String("start_date", startDateStr),
226
1x
        attribute.String("end_date", endDateStr),
227
1x
    )
228
1x

229
1x
    // Get word history
230
1x
    words, err := h.wordOfTheDayService.GetWordHistory(ctx, userID, startDate, endDate)
231
1x
    if err != nil {
232
        h.logger.Error(ctx, "Failed to get word of the day history", err, map[string]interface{}{
233
            "user_id":    userID,
234
            "start_date": startDateStr,
235
            "end_date":   endDateStr,
236
        })
237
        HandleAppError(c, contextutils.WrapError(err, "failed to get word history"))
238
        return
239
    }
240

241
1x
    if words == nil {
242
1x
        words = make([]*models.WordOfTheDayDisplay, 0)
243
1x
    }
244

245
1x
    c.JSON(http.StatusOK, gin.H{
246
1x
        "words": words,
247
1x
        "count": len(words),
248
1x
    })
249
}
250

251
// renderEmbedHTML renders the embed HTML template
252
func (h *WordOfTheDayHandler) renderEmbedHTML(word *models.WordOfTheDayDisplay) string {
253
    if word == nil {
254
        // Gracefully handle missing word to avoid panics in tests/environments with no data
255
        return "<html><head><meta charset=\"UTF-8\"></head><body>Word of the Day is unavailable.</body></html>"
256
    }
257
    const embedTemplate = `
258
<!DOCTYPE html>
259
<html lang="en">
260
<head>
261
    <meta charset="UTF-8">
262
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
263
    <title>Word of the Day</title>
264
    <style>
265
        * {
266
            margin: 0;
267
            padding: 0;
268
            box-sizing: border-box;
269
        }
270
        body {
271
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
272
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
273
            color: #333;
274
            padding: 20px;
275
            min-height: 100vh;
276
            display: flex;
277
            align-items: center;
278
            justify-content: center;
279
        }
280
        .card {
281
            background: white;
282
            border-radius: 16px;
283
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
284
            padding: 30px;
285
            max-width: 500px;
286
            width: 100%;
287
        }
288
        .date {
289
            color: #667eea;
290
            font-size: 14px;
291
            font-weight: 600;
292
            text-transform: uppercase;
293
            letter-spacing: 1px;
294
            margin-bottom: 10px;
295
        }
296
        .word {
297
            font-size: 48px;
298
            font-weight: bold;
299
            color: #1a1a1a;
300
            margin-bottom: 10px;
301
            line-height: 1.2;
302
        }
303
        .translation {
304
            font-size: 24px;
305
            color: #667eea;
306
            margin-bottom: 20px;
307
            font-style: italic;
308
        }
309
        .sentence {
310
            font-size: 18px;
311
            line-height: 1.6;
312
            color: #555;
313
            background: #f7f7f7;
314
            padding: 20px;
315
            border-radius: 8px;
316
            border-left: 4px solid #667eea;
317
            margin-bottom: 15px;
318
        }
319
        .meta {
320
            display: flex;
321
            gap: 10px;
322
            flex-wrap: wrap;
323
            margin-top: 20px;
324
        }
325
        .badge {
326
            background: #e0e7ff;
327
            color: #667eea;
328
            padding: 6px 12px;
329
            border-radius: 20px;
330
            font-size: 12px;
331
            font-weight: 600;
332
        }
333
        .explanation {
334
            font-size: 14px;
335
            color: #666;
336
            margin-top: 15px;
337
            padding: 15px;
338
            background: #fafafa;
339
            border-radius: 8px;
340
            border-left: 3px solid #764ba2;
341
        }
342
    </style>
343
</head>
344
<body>
345
    <div class="card">
346
        <div class="date">{{.FormattedDate}}</div>
347
        <div class="word">{{.Word}}</div>
348
        <div class="translation">{{.Translation}}</div>
349
        {{if .Sentence}}
350
        <div class="sentence">{{.Sentence}}</div>
351
        {{end}}
352
        <div class="meta">
353
            {{if .Language}}<span class="badge">{{.Language}}</span>{{end}}
354
            {{if .Level}}<span class="badge">{{.Level}}</span>{{end}}
355
            {{if .TopicCategory}}<span class="badge">{{.TopicCategory}}</span>{{end}}
356
        </div>
357
        {{if .Explanation}}
358
        <div class="explanation">{{.Explanation}}</div>
359
        {{end}}
360
    </div>
361
</body>
362
</html>
363
`
364

365
    tmpl, err := template.New("embed").Parse(embedTemplate)
366
    if err != nil {
367
        return fmt.Sprintf("<html><body>Error rendering template: %v</body></html>", err)
368
    }
369

370
    data := struct {
371
        FormattedDate string
372
        Word          string
373
        Translation   string
374
        Sentence      string
375
        Language      string
376
        Level         string
377
        TopicCategory string
378
        Explanation   string
379
    }{
380
        FormattedDate: word.Date.Format("January 2, 2006"),
381
        Word:          word.Word,
382
        Translation:   word.Translation,
383
        Sentence:      word.Sentence,
384
        Language:      word.Language,
385
        Level:         word.Level,
386
        TopicCategory: word.TopicCategory,
387
        Explanation:   word.Explanation,
388
    }
389

390
    var buf strings.Builder
391
    if err := tmpl.Execute(&buf, data); err != nil {
392
        return fmt.Sprintf("<html><body>Error executing template: %v</body></html>", err)
393
    }
394

395
    return buf.String()
396
}
397


			
quizapp internal handlers worker_admin_handler.go
43.6%
Statements
164/376
1
package handlers
2

3
import (
4
    "errors"
5
    "fmt"
6
    "html/template"
7
    "net/http"
8
    "strconv"
9
    "strings"
10
    "time"
11

12
    "quizapp/internal/config"
13
    "quizapp/internal/observability"
14
    "quizapp/internal/services"
15
    contextutils "quizapp/internal/utils"
16
    "quizapp/internal/worker"
17

18
    "github.com/gin-gonic/gin"
19
    "go.opentelemetry.io/otel/attribute"
20
)
21

22
// WorkerAdminHandler handles worker administration endpoints
23
type WorkerAdminHandler struct {
24
    userService          services.UserServiceInterface
25
    questionService      services.QuestionServiceInterface
26
    aiService            services.AIServiceInterface
27
    config               *config.Config
28
    worker               *worker.Worker
29
    workerService        services.WorkerServiceInterface
30
    templates            *template.Template
31
    learningService      services.LearningServiceInterface
32
    dailyQuestionService services.DailyQuestionServiceInterface
33
    logger               *observability.Logger
34
}
35

36
// NewWorkerAdminHandlerWithLogger creates a new WorkerAdminHandler
37
func NewWorkerAdminHandlerWithLogger(
38
    userService services.UserServiceInterface,
39
    questionService services.QuestionServiceInterface,
40
    aiService services.AIServiceInterface,
41
    cfg *config.Config,
42
    worker *worker.Worker,
43
    workerService services.WorkerServiceInterface,
44
    learningService services.LearningServiceInterface,
45
    dailyQuestionService services.DailyQuestionServiceInterface,
46
    logger *observability.Logger,
47
11x
) *WorkerAdminHandler {
48
11x
    return &WorkerAdminHandler{
49
11x
        userService:          userService,
50
11x
        questionService:      questionService,
51
11x
        aiService:            aiService,
52
11x
        config:               cfg,
53
11x
        worker:               worker,
54
11x
        workerService:        workerService,
55
11x
        templates:            nil,
56
11x
        learningService:      learningService,
57
11x
        dailyQuestionService: dailyQuestionService,
58
11x
        logger:               logger,
59
11x
    }
60
11x
}
61

62
// GetWorkerDetails returns detailed worker information
63
3x
func (h *WorkerAdminHandler) GetWorkerDetails(c *gin.Context) {
64
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_worker_details")
65
3x
    defer span.End()
66
3x
    // Get worker status from local instance if available
67
3x
    var localStatus worker.Status
68
3x
    var localHistory []worker.RunRecord
69
3x
    if h.worker != nil {
70
3x
        localStatus = h.worker.GetStatus()
71
3x
        localHistory = h.worker.GetHistory()
72
3x
    }
73

74
    // Get global pause status
75
3x
    globalPaused, err := h.workerService.IsGlobalPaused(ctx)
76
3x
    if err != nil {
77
        // Log the error but continue with default value
78
        h.logger.Warn(ctx, "Failed to get global pause status", map[string]interface{}{"error": err.Error()})
79
        globalPaused = false
80
    }
81

82
3x
    response := gin.H{
83
3x
        "status":        localStatus,
84
3x
        "history":       localHistory,
85
3x
        "global_paused": globalPaused,
86
3x
    }
87
3x

88
3x
    c.JSON(http.StatusOK, response)
89
}
90

91
// GetActivityLogs returns recent activity logs from the worker
92
1x
func (h *WorkerAdminHandler) GetActivityLogs(c *gin.Context) {
93
1x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_activity_logs")
94
1x
    defer span.End()
95
1x
    if h.worker == nil {
96
        HandleAppError(c, contextutils.ErrServiceUnavailable)
97
        return
98
    }
99

100
1x
    logs := h.worker.GetActivityLogs()
101
1x
    c.JSON(http.StatusOK, gin.H{"logs": logs})
102
}
103

104
// PauseWorker pauses the worker globally
105
3x
func (h *WorkerAdminHandler) PauseWorker(c *gin.Context) {
106
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "pause_worker")
107
3x
    defer span.End()
108
3x
    if err := h.workerService.SetGlobalPause(ctx, true); err != nil {
109
        HandleAppError(c, contextutils.WrapError(err, "failed to pause worker globally"))
110
        return
111
    }
112

113
    // Also pause the local worker instance if available
114
3x
    if h.worker != nil {
115
2x
        h.worker.Pause(ctx)
116
2x
    }
117

118
3x
    c.JSON(http.StatusOK, gin.H{"message": "Worker paused globally"})
119
}
120

121
// ResumeWorker resumes the worker globally
122
3x
func (h *WorkerAdminHandler) ResumeWorker(c *gin.Context) {
123
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "resume_worker")
124
3x
    defer span.End()
125
3x
    if err := h.workerService.SetGlobalPause(ctx, false); err != nil {
126
        HandleAppError(c, contextutils.WrapError(err, "failed to resume worker globally"))
127
        return
128
    }
129

130
    // Also resume the local worker instance if available
131
3x
    if h.worker != nil {
132
2x
        h.worker.Resume(ctx)
133
2x
    }
134

135
3x
    c.JSON(http.StatusOK, gin.H{"message": "Worker resumed globally"})
136
}
137

138
// GetWorkerStatus returns current worker status
139
6x
func (h *WorkerAdminHandler) GetWorkerStatus(c *gin.Context) {
140
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_worker_status")
141
6x
    defer span.End()
142
6x
    instance := c.DefaultQuery("instance", "default")
143
6x

144
6x
    status, err := h.workerService.GetWorkerStatus(ctx, instance)
145
6x
    if err != nil {
146
        HandleAppError(c, contextutils.WrapError(err, "failed to get worker status"))
147
        return
148
    }
149

150
6x
    c.JSON(http.StatusOK, status)
151
}
152

153
// TriggerWorkerRun triggers a manual worker run
154
2x
func (h *WorkerAdminHandler) TriggerWorkerRun(c *gin.Context) {
155
2x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "trigger_worker_run")
156
2x
    defer span.End()
157
2x
    if h.worker != nil {
158
2x
        h.worker.TriggerManualRun()
159
2x
        c.JSON(http.StatusOK, gin.H{"message": "Worker run triggered"})
160
2x
    } else {
161
        HandleAppError(c, contextutils.ErrServiceUnavailable)
162
    }
163
}
164

165
// PauseWorkerUser pauses question generation for a specific user
166
4x
func (h *WorkerAdminHandler) PauseWorkerUser(c *gin.Context) {
167
4x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "pause_user")
168
4x
    defer span.End()
169
4x
    var req struct {
170
4x
        UserID int `json:"user_id" binding:"required"`
171
4x
    }
172
4x

173
4x
    if err := c.ShouldBindJSON(&req); err != nil {
174
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
175
1x
            contextutils.ErrorCodeInvalidInput,
176
1x
            contextutils.SeverityWarn,
177
1x
            "Invalid request",
178
1x
            "",
179
1x
            err,
180
1x
        ))
181
1x
        return
182
1x
    }
183

184
3x
    if err := h.workerService.SetUserPause(ctx, req.UserID, true); err != nil {
185
        HandleAppError(c, contextutils.WrapError(err, "failed to pause user"))
186
        return
187
    }
188

189
3x
    c.JSON(http.StatusOK, gin.H{"message": "User paused successfully"})
190
}
191

192
// ResumeWorkerUser resumes question generation for a specific user
193
3x
func (h *WorkerAdminHandler) ResumeWorkerUser(c *gin.Context) {
194
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "resume_user")
195
3x
    defer span.End()
196
3x
    var req struct {
197
3x
        UserID int `json:"user_id" binding:"required"`
198
3x
    }
199
3x

200
3x
    if err := c.ShouldBindJSON(&req); err != nil {
201
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
202
1x
            contextutils.ErrorCodeInvalidInput,
203
1x
            contextutils.SeverityWarn,
204
1x
            "Invalid request",
205
1x
            "",
206
1x
            err,
207
1x
        ))
208
1x
        return
209
1x
    }
210

211
2x
    if err := h.workerService.SetUserPause(ctx, req.UserID, false); err != nil {
212
        HandleAppError(c, contextutils.WrapError(err, "failed to resume user"))
213
        return
214
    }
215

216
2x
    c.JSON(http.StatusOK, gin.H{"message": "User resumed successfully"})
217
}
218

219
// GetWorkerUsers returns basic user list for worker controls
220
1x
func (h *WorkerAdminHandler) GetWorkerUsers(c *gin.Context) {
221
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_worker_users")
222
1x
    defer span.End()
223
1x
    users, err := h.userService.GetAllUsers(ctx)
224
1x
    if err != nil {
225
        HandleAppError(c, contextutils.WrapError(err, "failed to get users"))
226
        return
227
    }
228

229
    // Add pause status for each user
230
1x
    var userList []gin.H
231
1x
    for _, user := range users {
232
1x
        isPaused, _ := h.workerService.IsUserPaused(ctx, user.ID)
233
1x
        userList = append(userList, gin.H{
234
1x
            "id":        user.ID,
235
1x
            "username":  user.Username,
236
1x
            "is_paused": isPaused,
237
1x
        })
238
1x
    }
239

240
1x
    c.JSON(http.StatusOK, gin.H{"users": userList})
241
}
242

243
// GetSystemHealth returns comprehensive system health
244
2x
func (h *WorkerAdminHandler) GetSystemHealth(c *gin.Context) {
245
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_system_health")
246
2x
    defer span.End()
247
2x
    health, err := h.workerService.GetWorkerHealth(ctx)
248
2x
    if err != nil {
249
        HandleAppError(c, contextutils.WrapError(err, "failed to get system health"))
250
        return
251
    }
252

253
2x
    c.JSON(http.StatusOK, health)
254
}
255

256
// GetAIConcurrencyStats returns AI service concurrency metrics from the worker
257
1x
func (h *WorkerAdminHandler) GetAIConcurrencyStats(c *gin.Context) {
258
1x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_ai_concurrency_stats")
259
1x
    defer span.End()
260
1x
    if h.aiService == nil {
261
        HandleAppError(c, contextutils.ErrAIProviderUnavailable)
262
        return
263
    }
264

265
1x
    stats := h.aiService.GetConcurrencyStats()
266
1x
    c.JSON(http.StatusOK, gin.H{
267
1x
        "ai_concurrency": stats,
268
1x
    })
269
}
270

271
// GetPriorityAnalytics returns priority system analytics
272
6x
func (h *WorkerAdminHandler) GetPriorityAnalytics(c *gin.Context) {
273
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_priority_analytics")
274
6x
    defer span.End()
275
6x
    // Get priority score distribution
276
6x
    distribution, err := h.learningService.GetPriorityScoreDistribution(ctx)
277
6x
    if err != nil {
278
1x
        h.logger.Error(ctx, "Error getting priority score distribution", err, map[string]interface{}{})
279
1x
        distribution = map[string]interface{}{
280
1x
            "high":    0,
281
1x
            "medium":  0,
282
1x
            "low":     0,
283
1x
            "average": 0.0,
284
1x
        }
285
1x
    }
286

287
    // Get high priority questions
288
6x
    highPriorityQuestions, err := h.learningService.GetHighPriorityQuestions(ctx, 5)
289
6x
    if err != nil {
290
        h.logger.Error(ctx, "Error getting high priority questions", err, map[string]interface{}{})
291
        highPriorityQuestions = []map[string]interface{}{}
292
    }
293

294
6x
    response := gin.H{
295
6x
        "distribution":          distribution,
296
6x
        "highPriorityQuestions": highPriorityQuestions,
297
6x
    }
298
6x

299
6x
    c.JSON(http.StatusOK, response)
300
}
301

302
// GetUserPriorityAnalytics returns priority analytics for a specific user
303
3x
func (h *WorkerAdminHandler) GetUserPriorityAnalytics(c *gin.Context) {
304
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_user_priority_analytics")
305
3x
    defer span.End()
306
3x
    userIDStr := c.Param("userID")
307
3x
    userID, err := strconv.Atoi(userIDStr)
308
3x
    if err != nil {
309
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
310
1x
        return
311
1x
    }
312

313
    // Verify user exists
314
2x
    user, err := h.userService.GetUserByID(ctx, userID)
315
2x
    if err != nil || user == nil {
316
1x
        HandleAppError(c, contextutils.ErrRecordNotFound)
317
1x
        return
318
1x
    }
319

320
    // Get user-specific priority score distribution
321
1x
    distribution, err := h.learningService.GetUserPriorityScoreDistribution(ctx, userID)
322
1x
    if err != nil {
323
        h.logger.Error(ctx, "Error getting user priority score distribution", err, map[string]interface{}{})
324
        distribution = map[string]interface{}{
325
            "high":    0,
326
            "medium":  0,
327
            "low":     0,
328
            "average": 0.0,
329
        }
330
    }
331

332
    // Get user's high priority questions
333
1x
    highPriorityQuestions, err := h.learningService.GetUserHighPriorityQuestions(ctx, userID, 10)
334
1x
    if err != nil {
335
        h.logger.Error(ctx, "Error getting user high priority questions", err, map[string]interface{}{})
336
        highPriorityQuestions = []map[string]interface{}{}
337
    }
338

339
    // Get user's weak areas
340
1x
    weakAreas, err := h.learningService.GetUserWeakAreas(ctx, userID, 5)
341
1x
    if err != nil {
342
        h.logger.Error(ctx, "Error getting user weak areas", err, map[string]interface{}{})
343
        weakAreas = []map[string]interface{}{}
344
    }
345

346
    // Get user's learning preferences
347
1x
    preferences, err := h.learningService.GetUserLearningPreferences(ctx, userID)
348
1x
    if err != nil {
349
        h.logger.Error(ctx, "Error getting user learning preferences", err, map[string]interface{}{})
350
        preferences = nil
351
    }
352

353
1x
    response := gin.H{
354
1x
        "user": gin.H{
355
1x
            "id":       user.ID,
356
1x
            "username": user.Username,
357
1x
        },
358
1x
        "distribution":          distribution,
359
1x
        "highPriorityQuestions": highPriorityQuestions,
360
1x
        "weakAreas":             weakAreas,
361
1x
        "learningPreferences":   preferences,
362
1x
    }
363
1x

364
1x
    c.JSON(http.StatusOK, response)
365
}
366

367
// GetUserPerformanceAnalytics returns user performance analytics
368
4x
func (h *WorkerAdminHandler) GetUserPerformanceAnalytics(c *gin.Context) {
369
4x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_user_performance_analytics")
370
4x
    defer span.End()
371
4x
    // Get weak areas by topic
372
4x
    weakAreas, err := h.learningService.GetWeakAreasByTopic(ctx, 5)
373
4x
    if err != nil {
374
        h.logger.Error(ctx, "Error getting weak areas", err, map[string]interface{}{})
375
        weakAreas = []map[string]interface{}{}
376
    }
377

378
    // Get learning preferences usage
379
4x
    learningPreferences, err := h.learningService.GetLearningPreferencesUsage(ctx)
380
4x
    if err != nil {
381
        h.logger.Error(ctx, "Error getting learning preferences usage", err, map[string]interface{}{})
382
        learningPreferences = map[string]interface{}{}
383
    }
384

385
4x
    response := gin.H{
386
4x
        "weakAreas":           weakAreas,
387
4x
        "learningPreferences": learningPreferences,
388
4x
    }
389
4x

390
4x
    c.JSON(http.StatusOK, response)
391
}
392

393
// GetGenerationIntelligence returns question generation intelligence
394
4x
func (h *WorkerAdminHandler) GetGenerationIntelligence(c *gin.Context) {
395
4x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_generation_intelligence")
396
4x
    defer span.End()
397
4x
    // Get gap analysis
398
4x
    gapAnalysis, err := h.learningService.GetQuestionTypeGaps(ctx)
399
4x
    if err != nil {
400
        h.logger.Error(ctx, "Error getting gap analysis", err, map[string]interface{}{})
401
        gapAnalysis = []map[string]interface{}{}
402
    }
403

404
    // Get generation suggestions
405
4x
    generationSuggestions, err := h.learningService.GetGenerationSuggestions(ctx)
406
4x
    if err != nil {
407
        h.logger.Error(ctx, "Error getting generation suggestions", err, map[string]interface{}{})
408
        generationSuggestions = []map[string]interface{}{}
409
    }
410

411
    // Ensure we always return arrays, not nil
412
4x
    if gapAnalysis == nil {
413
2x
        gapAnalysis = []map[string]interface{}{}
414
2x
    }
415
4x
    if generationSuggestions == nil {
416
2x
        generationSuggestions = []map[string]interface{}{}
417
2x
    }
418

419
4x
    response := gin.H{
420
4x
        "gapAnalysis":           gapAnalysis,
421
4x
        "generationSuggestions": generationSuggestions,
422
4x
    }
423
4x

424
4x
    c.JSON(http.StatusOK, response)
425
}
426

427
// GetSystemHealthAnalytics returns system health analytics
428
3x
func (h *WorkerAdminHandler) GetSystemHealthAnalytics(c *gin.Context) {
429
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_system_health_analytics")
430
3x
    defer span.End()
431
3x
    // Get performance metrics
432
3x
    performance, err := h.learningService.GetPrioritySystemPerformance(ctx)
433
3x
    if err != nil {
434
        h.logger.Error(ctx, "Error getting performance metrics", err, map[string]interface{}{})
435
        performance = map[string]interface{}{}
436
    }
437

438
    // Get background jobs status
439
3x
    backgroundJobs, err := h.learningService.GetBackgroundJobsStatus(ctx)
440
3x
    if err != nil {
441
        h.logger.Error(ctx, "Error getting background jobs status", err, map[string]interface{}{})
442
        backgroundJobs = map[string]interface{}{}
443
    }
444

445
3x
    response := gin.H{
446
3x
        "performance":    performance,
447
3x
        "backgroundJobs": backgroundJobs,
448
3x
    }
449
3x

450
3x
    c.JSON(http.StatusOK, response)
451
}
452

453
// GetUserComparisonAnalytics returns comparison analytics between users
454
6x
func (h *WorkerAdminHandler) GetUserComparisonAnalytics(c *gin.Context) {
455
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_user_comparison_analytics")
456
6x
    defer span.End()
457
6x
    userIDsParam := c.Query("user_ids")
458
6x
    if userIDsParam == "" {
459
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
460
1x
        return
461
1x
    }
462

463
    // Split comma-separated user IDs
464
5x
    userIDsStr := strings.Split(userIDsParam, ",")
465
5x
    if len(userIDsStr) == 0 {
466
        HandleAppError(c, contextutils.ErrMissingRequired)
467
        return
468
    }
469

470
5x
    var userIDs []int
471
5x
    for _, idStr := range userIDsStr {
472
6x
        idStr = strings.TrimSpace(idStr) // Remove whitespace
473
6x
        if idStr == "" {
474
            continue
475
        }
476
6x
        id, err := strconv.Atoi(idStr)
477
6x
        if err != nil {
478
2x
            HandleAppError(c, contextutils.NewAppErrorWithCause(
479
2x
                contextutils.ErrorCodeInvalidFormat,
480
2x
                contextutils.SeverityWarn,
481
2x
                "Invalid user ID",
482
2x
                idStr,
483
2x
                err,
484
2x
            ))
485
2x
            return
486
2x
        }
487
4x
        userIDs = append(userIDs, id)
488
    }
489

490
3x
    if len(userIDs) == 0 {
491
        HandleAppError(c, contextutils.ErrMissingRequired)
492
        return
493
    }
494

495
    // Get comparison data for each user
496
3x
    var comparisonData []gin.H
497
3x
    for _, userID := range userIDs {
498
4x
        user, err := h.userService.GetUserByID(ctx, userID)
499
4x
        if err != nil {
500
            continue // Skip invalid users
501
        }
502

503
4x
        distribution, _ := h.learningService.GetUserPriorityScoreDistribution(ctx, userID)
504
4x
        weakAreas, _ := h.learningService.GetUserWeakAreas(ctx, userID, 3)
505
4x

506
4x
        userData := gin.H{
507
4x
            "user": gin.H{
508
4x
                "id":       user.ID,
509
4x
                "username": user.Username,
510
4x
            },
511
4x
            "distribution": distribution,
512
4x
            "weakAreas":    weakAreas,
513
4x
        }
514
4x
        comparisonData = append(comparisonData, userData)
515
    }
516

517
3x
    c.JSON(http.StatusOK, gin.H{"comparison": comparisonData})
518
}
519

520
// GetConfigz returns the merged config as pretty-printed JSON
521
1x
func (h *WorkerAdminHandler) GetConfigz(c *gin.Context) {
522
1x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_configz")
523
1x
    defer span.End()
524
1x
    c.IndentedJSON(http.StatusOK, h.config)
525
1x
}
526

527
// GetNotificationStats returns comprehensive notification statistics
528
func (h *WorkerAdminHandler) GetNotificationStats(c *gin.Context) {
529
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_notification_stats")
530
    defer span.End()
531

532
    // Get notification statistics from database
533
    stats, err := h.workerService.GetNotificationStats(ctx)
534
    if err != nil {
535
        h.logger.Error(ctx, "Failed to get notification stats", err, nil)
536
        c.JSON(http.StatusInternalServerError, gin.H{
537
            "error":   "Failed to get notification statistics",
538
            "details": err.Error(),
539
        })
540
        return
541
    }
542

543
    c.JSON(http.StatusOK, stats)
544
}
545

546
// GetNotificationErrors returns paginated notification errors
547
func (h *WorkerAdminHandler) GetNotificationErrors(c *gin.Context) {
548
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_notification_errors")
549
    defer span.End()
550

551
    // Parse pagination and filters
552
    page, pageSize := ParsePagination(c, 1, 20, 100)
553
    f := ParseFilters(c, "error_type", "notification_type", "resolved")
554
    errorType := f["error_type"]
555
    notificationType := f["notification_type"]
556
    resolved := f["resolved"]
557

558
    // Get notification errors from database
559
    errors, pagination, stats, err := h.workerService.GetNotificationErrors(ctx, page, pageSize, errorType, notificationType, resolved)
560
    if err != nil {
561
        h.logger.Error(ctx, "Failed to get notification errors", err, nil)
562
        c.JSON(http.StatusInternalServerError, gin.H{
563
            "error":   "Failed to get notification errors",
564
            "details": err.Error(),
565
        })
566
        return
567
    }
568

569
    WritePaginated(c, "errors", errors, pagination, gin.H{"stats": stats})
570
}
571

572
// GetSentNotifications returns paginated sent notifications
573
func (h *WorkerAdminHandler) GetSentNotifications(c *gin.Context) {
574
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_sent_notifications")
575
    defer span.End()
576

577
    // Parse pagination and filters
578
    page, pageSize := ParsePagination(c, 1, 20, 100)
579
    f := ParseFilters(c, "notification_type", "status", "sent_after", "sent_before")
580
    notificationType := f["notification_type"]
581
    status := f["status"]
582
    sentAfter := f["sent_after"]
583
    sentBefore := f["sent_before"]
584

585
    // Get sent notifications from database
586
    notifications, pagination, stats, err := h.workerService.GetSentNotifications(ctx, page, pageSize, notificationType, status, sentAfter, sentBefore)
587
    if err != nil {
588
        h.logger.Error(ctx, "Failed to get sent notifications", err, nil)
589
        c.JSON(http.StatusInternalServerError, gin.H{
590
            "error":   "Failed to get sent notifications",
591
            "details": err.Error(),
592
        })
593
        return
594
    }
595

596
    WritePaginated(c, "notifications", notifications, pagination, gin.H{"stats": stats})
597
}
598

599
// CreateTestSentNotification creates a test sent notification for testing
600
func (h *WorkerAdminHandler) CreateTestSentNotification(c *gin.Context) {
601
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "create_test_sent_notification")
602
    defer span.End()
603

604
    // Parse request body
605
    var request struct {
606
        UserID           int    `json:"user_id" binding:"required"`
607
        NotificationType string `json:"notification_type" binding:"required"`
608
        Subject          string `json:"subject" binding:"required"`
609
        TemplateName     string `json:"template_name" binding:"required"`
610
        Status           string `json:"status" binding:"required"`
611
        ErrorMessage     string `json:"error_message"`
612
    }
613

614
    if err := c.ShouldBindJSON(&request); err != nil {
615
        HandleAppError(c, contextutils.NewAppErrorWithCause(
616
            contextutils.ErrorCodeInvalidInput,
617
            contextutils.SeverityWarn,
618
            "Invalid request body",
619
            "",
620
            err,
621
        ))
622
        return
623
    }
624

625
    // Create test notification
626
    err := h.workerService.CreateTestSentNotification(ctx, request.UserID, request.NotificationType, request.Subject, request.TemplateName, request.Status, request.ErrorMessage)
627
    if err != nil {
628
        h.logger.Error(ctx, "Failed to create test sent notification", err, map[string]interface{}{
629
            "user_id":           request.UserID,
630
            "notification_type": request.NotificationType,
631
        })
632
        c.JSON(http.StatusInternalServerError, gin.H{
633
            "error":   "Failed to create test sent notification",
634
            "details": err.Error(),
635
        })
636
        return
637
    }
638

639
    c.JSON(http.StatusOK, gin.H{"message": "Test sent notification created successfully"})
640
}
641

642
// ForceSendNotification forces sending a notification to a user, bypassing normal checks
643
func (h *WorkerAdminHandler) ForceSendNotification(c *gin.Context) {
644
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "force_send_notification")
645
    defer span.End()
646

647
    // Parse request body
648
    var request struct {
649
        Username string `json:"username" binding:"required"`
650
    }
651

652
    if err := c.ShouldBindJSON(&request); err != nil {
653
        HandleAppError(c, contextutils.NewAppErrorWithCause(
654
            contextutils.ErrorCodeInvalidInput,
655
            contextutils.SeverityWarn,
656
            "Invalid request body",
657
            "",
658
            err,
659
        ))
660
        return
661
    }
662

663
    // Get user by username
664
    user, err := h.userService.GetUserByUsername(ctx, request.Username)
665
    if err != nil {
666
        h.logger.Error(ctx, "Failed to get user by username", err, map[string]interface{}{
667
            "username": request.Username,
668
        })
669
        c.JSON(http.StatusInternalServerError, gin.H{
670
            "error":   "Failed to get user",
671
            "details": err.Error(),
672
        })
673
        return
674
    }
675

676
    if user == nil {
677
        HandleAppError(c, contextutils.NewAppError(
678
            contextutils.ErrorCodeRecordNotFound,
679
            contextutils.SeverityInfo,
680
            fmt.Sprintf("User '%s' not found", request.Username),
681
            "",
682
        ))
683
        return
684
    }
685

686
    // Check if user has email address
687
    if !user.Email.Valid || user.Email.String == "" {
688
        HandleAppError(c, contextutils.ErrMissingRequired)
689
        return
690
    }
691

692
    // Get user's learning preferences to check daily reminder setting
693
    prefs, err := h.learningService.GetUserLearningPreferences(ctx, user.ID)
694
    if err != nil {
695
        h.logger.Error(ctx, "Failed to get user learning preferences", err, map[string]interface{}{
696
            "user_id": user.ID,
697
        })
698
        c.JSON(http.StatusInternalServerError, gin.H{
699
            "error":   "Failed to get user preferences",
700
            "details": err.Error(),
701
        })
702
        return
703
    }
704

705
    // Check if daily reminders are enabled for this user
706
    if prefs == nil || !prefs.DailyReminderEnabled {
707
        HandleAppError(c, contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityWarn, "User has daily reminders disabled", ""))
708
        return
709
    }
710

711
    // Force send the daily reminder (bypassing time and date checks)
712
    subject := "Time for your daily quiz! ð"
713
    status := "sent"
714
    errorMsg := ""
715

716
    // Get email service from worker
717
    emailService := h.worker.GetEmailService()
718
    if emailService == nil {
719
        HandleAppError(c, contextutils.ErrServiceUnavailable)
720
        return
721
    }
722

723
    // Send the email
724
    if err := emailService.SendDailyReminder(ctx, user); err != nil {
725
        h.logger.Error(ctx, "Failed to send forced daily reminder", err, map[string]interface{}{
726
            "user_id": user.ID,
727
            "email":   user.Email.String,
728
        })
729
        HandleAppError(c, contextutils.WrapError(err, "failed to send notification"))
730
        return
731
    }
732

733
    // Record the sent notification in the database
734
    if err := emailService.RecordSentNotification(ctx, user.ID, "daily_reminder", subject, "daily_reminder", status, errorMsg); err != nil {
735
        h.logger.Error(ctx, "Failed to record sent notification", err, map[string]interface{}{
736
            "user_id": user.ID,
737
        })
738
        // Don't fail the request if recording fails
739
    }
740

741
    // Update the last reminder sent timestamp for this user
742
    if err := h.learningService.UpdateLastDailyReminderSent(ctx, user.ID); err != nil {
743
        h.logger.Error(ctx, "Failed to update last daily reminder sent timestamp", err, map[string]interface{}{
744
            "user_id": user.ID,
745
        })
746
        // Don't fail the request if timestamp update fails
747
    }
748

749
    h.logger.Info(ctx, "Forced notification sent successfully", map[string]interface{}{
750
        "user_id":  user.ID,
751
        "username": user.Username,
752
        "email":    user.Email.String,
753
    })
754

755
    c.JSON(http.StatusOK, gin.H{
756
        "message": "Notification sent successfully",
757
        "user": gin.H{
758
            "id":       user.ID,
759
            "username": user.Username,
760
            "email":    user.Email.String,
761
        },
762
        "notification": gin.H{
763
            "type":    "daily_reminder",
764
            "subject": subject,
765
            "status":  status,
766
        },
767
    })
768
}
769

770
// GetUserDailyQuestions returns daily questions for a specific user and date (admin only)
771
func (h *WorkerAdminHandler) GetUserDailyQuestions(c *gin.Context) {
772
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "admin_get_user_daily_questions")
773
    defer span.End()
774

775
    // Parse user ID
776
    userIDStr := c.Param("userId")
777
    userID, err := strconv.Atoi(userIDStr)
778
    if err != nil {
779
        HandleAppError(c, contextutils.ErrInvalidFormat)
780
        return
781
    }
782

783
    // Check if user exists
784
    user, err := h.userService.GetUserByID(ctx, userID)
785
    if err != nil {
786
        h.logger.Error(ctx, "Failed to get user for daily questions", err, map[string]interface{}{"user_id": userID})
787
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
788
        return
789
    }
790
    if user == nil {
791
        HandleAppError(c, contextutils.ErrRecordNotFound)
792
        return
793
    }
794

795
    // Parse date
796
    dateStr := c.Param("date")
797
    if dateStr == "" {
798
        HandleAppError(c, contextutils.ErrMissingRequired)
799
        return
800
    }
801

802
    date, err := time.Parse("2006-01-02", dateStr)
803
    if err != nil {
804
        HandleAppError(c, contextutils.ErrInvalidFormat)
805
        return
806
    }
807

808
    // Add span attributes for observability
809
    span.SetAttributes(
810
        observability.AttributeUserID(userID),
811
        attribute.String("date", dateStr),
812
    )
813

814
    // Get daily questions for the user and date
815
    questions, err := h.dailyQuestionService.GetDailyQuestions(ctx, userID, date)
816
    if err != nil {
817
        h.logger.Error(ctx, "Failed to get user daily questions", err, map[string]interface{}{
818
            "user_id": userID,
819
            "date":    dateStr,
820
        })
821
        c.JSON(http.StatusInternalServerError, gin.H{
822
            "error":   "Failed to get daily questions",
823
            "details": err.Error(),
824
        })
825
        return
826
    }
827

828
    // Convert to API format (similar to the daily question handler)
829
    apiQuestions := make([]gin.H, len(questions))
830
    for i, q := range questions {
831
        var completedAt *time.Time
832
        if q.CompletedAt.Valid {
833
            completedAt = &q.CompletedAt.Time
834
        }
835

836
        apiQuestions[i] = gin.H{
837
            "id":              q.ID,
838
            "user_id":         q.UserID,
839
            "question_id":     q.QuestionID,
840
            "assignment_date": q.AssignmentDate,
841
            "is_completed":    q.IsCompleted,
842
            "completed_at":    completedAt,
843
            "created_at":      q.CreatedAt,
844
            // Per-user stats for admin UI
845
            "user_shown_count":     q.DailyShownCount,
846
            "user_total_responses": q.UserTotalResponses,
847
            "user_correct_count":   q.UserCorrectCount,
848
            "user_incorrect_count": q.UserIncorrectCount,
849
            "question": gin.H{
850
                "id":                  q.Question.ID,
851
                "type":                q.Question.Type,
852
                "language":            q.Question.Language,
853
                "level":               q.Question.Level,
854
                "difficulty_score":    q.Question.DifficultyScore,
855
                "content":             q.Question.Content,
856
                "correct_answer":      q.Question.CorrectAnswer,
857
                "explanation":         q.Question.Explanation,
858
                "created_at":          q.Question.CreatedAt,
859
                "status":              q.Question.Status,
860
                "topic_category":      q.Question.TopicCategory,
861
                "grammar_focus":       q.Question.GrammarFocus,
862
                "vocabulary_domain":   q.Question.VocabularyDomain,
863
                "scenario":            q.Question.Scenario,
864
                "style_modifier":      q.Question.StyleModifier,
865
                "difficulty_modifier": q.Question.DifficultyModifier,
866
                "time_context":        q.Question.TimeContext,
867
            },
868
        }
869
    }
870

871
    c.JSON(http.StatusOK, gin.H{"questions": apiQuestions})
872
}
873

874
// RegenerateUserDailyQuestions clears and regenerates daily questions for a specific user and date (admin only)
875
func (h *WorkerAdminHandler) RegenerateUserDailyQuestions(c *gin.Context) {
876
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "admin_regenerate_user_daily_questions")
877
    defer span.End()
878

879
    // Parse user ID
880
    userIDStr := c.Param("userId")
881
    userID, err := strconv.Atoi(userIDStr)
882
    if err != nil {
883
        HandleAppError(c, contextutils.ErrInvalidFormat)
884
        return
885
    }
886

887
    // Check if user exists
888
    user, err := h.userService.GetUserByID(ctx, userID)
889
    if err != nil {
890
        h.logger.Error(ctx, "Failed to get user for daily questions regeneration", err, map[string]interface{}{"user_id": userID})
891
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
892
        return
893
    }
894
    if user == nil {
895
        HandleAppError(c, contextutils.ErrRecordNotFound)
896
        return
897
    }
898

899
    // Parse date
900
    dateStr := c.Param("date")
901
    if dateStr == "" {
902
        HandleAppError(c, contextutils.ErrMissingRequired)
903
        return
904
    }
905

906
    date, err := time.Parse("2006-01-02", dateStr)
907
    if err != nil {
908
        HandleAppError(c, contextutils.ErrInvalidFormat)
909
        return
910
    }
911

912
    // Add span attributes for observability
913
    span.SetAttributes(
914
        observability.AttributeUserID(userID),
915
        attribute.String("date", dateStr),
916
    )
917

918
    // For regeneration, we need to manually clear existing assignments and create new ones
919
    // Since the daily question service doesn't expose a direct way to clear assignments,
920
    // we'll use the worker service which should have database access for this admin operation
921

922
    // Check if worker service is available
923
    if h.workerService == nil {
924
        HandleAppError(c, contextutils.ErrServiceUnavailable)
925
        return
926
    }
927

928
    // Use the new RegenerateDailyQuestions method which clears existing assignments and creates new ones
929
    err = h.dailyQuestionService.RegenerateDailyQuestions(ctx, userID, date)
930
    if err != nil {
931
        h.logger.Error(ctx, "Failed to regenerate daily questions", err, map[string]interface{}{
932
            "user_id": userID,
933
            "date":    dateStr,
934
        })
935

936
        // If there are no questions available for assignment, prefer the structured error from the service
937
        var nqErr *services.NoQuestionsAvailableError
938
        if errors.As(err, &nqErr) {
939
            c.JSON(http.StatusBadRequest, gin.H{
940
                "error":                    "Failed to regenerate daily questions",
941
                "details":                  err.Error(),
942
                "user":                     gin.H{"id": user.ID, "username": user.Username, "language": nqErr.Language, "level": nqErr.Level},
943
                "candidate_count":          nqErr.CandidateCount,
944
                "candidate_ids":            nqErr.CandidateIDs,
945
                "total_matching_questions": nqErr.TotalMatching,
946
            })
947
            return
948
        }
949

950
        c.JSON(http.StatusInternalServerError, gin.H{
951
            "error":   "Failed to regenerate daily questions",
952
            "details": err.Error(),
953
        })
954
        return
955
    }
956

957
    h.logger.Info(ctx, "Daily questions regenerated successfully", map[string]interface{}{
958
        "user_id": userID,
959
        "date":    dateStr,
960
    })
961

962
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "Daily questions regenerated successfully. All existing assignments have been cleared and new questions assigned."})
963
}
964


			
quizapp internal middleware
47.8%
Statements
358/749
auth.go
73.2%
90/123
error_recovery.go
51.6%
48/93
schema_loader.go
52.5%
220/419
validation.go
0.0%
0/114
quizapp internal middleware validation.go
73.2%
Statements
90/123
1
// Package middleware provides authentication and authorization middleware for the Gin web framework.
2
package middleware
3

4
import (
5
    "context"
6
    "net/http"
7
    "strings"
8

9
    "quizapp/internal/models"
10

11
    "github.com/gin-contrib/sessions"
12
    "github.com/gin-gonic/gin"
13
)
14

15
// Session keys for storing user information
16
const (
17
    // UserIDKey is the key used to store user ID in session
18
    UserIDKey = "user_id"
19
    // UsernameKey is the key used to store username in session
20
    UsernameKey = "username"
21
    // AuthMethodKey is the key used to store authentication method
22
    AuthMethodKey = "auth_method"
23
    // APIKeyIDKey is the key used to store API key ID (for API key auth)
24
    APIKeyIDKey = "api_key_id"
25
)
26

27
// AuthMethod constants
28
const (
29
    AuthMethodSession = "session"
30
    AuthMethodAPIKey  = "api_key"
31
)
32

33
// AuthAPIKeyValidator is an interface for validating API keys
34
type AuthAPIKeyValidator interface {
35
    ValidateAPIKey(ctx context.Context, rawKey string) (*models.AuthAPIKey, error)
36
    UpdateLastUsed(ctx context.Context, keyID int) error
37
}
38

39
// AuthUserServiceGetter is an interface for getting user info
40
type AuthUserServiceGetter interface {
41
    GetUserByID(ctx context.Context, userID int) (*models.User, error)
42
}
43

44
// RequireAuth returns a middleware that requires authentication
45
// This version only supports session-based auth for backward compatibility
46
7x
func RequireAuth() gin.HandlerFunc {
47
7x
    return func(c *gin.Context) {
48
9x
        // Fall back to session authentication
49
9x
        session := sessions.Default(c)
50
9x
        userID := session.Get(UserIDKey)
51
9x

52
9x
        if userID == nil {
53
2x
            c.JSON(http.StatusUnauthorized, gin.H{
54
2x
                "error": "Authentication required",
55
2x
                "code":  "UNAUTHORIZED",
56
2x
            })
57
2x
            c.Abort()
58
2x
            return
59
2x
        }
60

61
        // Validate user_id is an integer
62
7x
        userIDInt, ok := userID.(int)
63
7x
        if !ok {
64
1x
            // Try to convert from float64 (JSON numbers are often stored as float64)
65
1x
            if userIDFloat, ok := userID.(float64); ok {
66
                userIDInt = int(userIDFloat)
67
            } else {
68
1x
                c.JSON(http.StatusUnauthorized, gin.H{
69
1x
                    "error": "Authentication required",
70
1x
                    "code":  "UNAUTHORIZED",
71
1x
                })
72
1x
                c.Abort()
73
1x
                return
74
1x
            }
75
        }
76

77
        // Validate username is a string and not empty
78
6x
        username := session.Get(UsernameKey)
79
6x
        if username == nil {
80
1x
            c.JSON(http.StatusUnauthorized, gin.H{
81
1x
                "error": "Authentication required",
82
1x
                "code":  "UNAUTHORIZED",
83
1x
            })
84
1x
            c.Abort()
85
1x
            return
86
1x
        }
87

88
5x
        usernameStr, ok := username.(string)
89
5x
        if !ok || usernameStr == "" {
90
            c.JSON(http.StatusUnauthorized, gin.H{
91
                "error": "Authentication required",
92
                "code":  "UNAUTHORIZED",
93
            })
94
            c.Abort()
95
            return
96
        }
97

98
        // Store user info in context for handlers to use
99
5x
        c.Set(UserIDKey, userIDInt)
100
5x
        c.Set(UsernameKey, usernameStr)
101
5x
        c.Set(AuthMethodKey, AuthMethodSession)
102
5x

103
5x
        c.Next()
104
    }
105
}
106

107
// RequireAuthWithAPIKey returns a middleware that requires authentication via API key or session
108
// It checks for API key authentication first, then falls back to session authentication
109
4x
func RequireAuthWithAPIKey(apiKeyService AuthAPIKeyValidator, userService AuthUserServiceGetter) gin.HandlerFunc {
110
4x
    return func(c *gin.Context) {
111
4x
        // Check for API key authentication first
112
4x
        var rawKey string
113
4x
        authHeader := c.GetHeader("Authorization")
114
4x
        if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
115
3x
            rawKey = strings.TrimPrefix(authHeader, "Bearer ")
116
3x
        } else {
117
1x
            // Check for API key in query parameter
118
1x
            rawKey = c.Query("api_key")
119
1x
        }
120

121
4x
        if rawKey != "" {
122
3x
            // Validate API key
123
3x
            apiKey, err := apiKeyService.ValidateAPIKey(c.Request.Context(), rawKey)
124
3x
            if err == nil && apiKey != nil {
125
2x
                // Check permission level against request method
126
2x
                if !apiKey.CanPerformMethod(c.Request.Method) {
127
1x
                    c.JSON(http.StatusForbidden, gin.H{
128
1x
                        "error": "This API key does not have permission for this operation",
129
1x
                        "code":  "FORBIDDEN",
130
1x
                    })
131
1x
                    c.Abort()
132
1x
                    return
133
1x
                }
134

135
                // Get user info to set username in context
136
1x
                user, err := userService.GetUserByID(c.Request.Context(), apiKey.UserID)
137
1x
                if err != nil || user == nil {
138
                    c.JSON(http.StatusUnauthorized, gin.H{
139
                        "error": "Invalid API key - user not found",
140
                        "code":  "UNAUTHORIZED",
141
                    })
142
                    c.Abort()
143
                    return
144
                }
145

146
                // Set user context
147
1x
                c.Set(UserIDKey, apiKey.UserID)
148
1x
                c.Set(UsernameKey, user.Username)
149
1x
                c.Set(AuthMethodKey, AuthMethodAPIKey)
150
1x
                c.Set(APIKeyIDKey, apiKey.ID)
151
1x

152
1x
                // Update last used timestamp asynchronously
153
1x
                go func() {
154
1x
                    _ = apiKeyService.UpdateLastUsed(context.Background(), apiKey.ID)
155
1x
                }()
156

157
1x
                c.Next()
158
1x
                return
159
            }
160
            // If we got here with a key (from header or query), it's invalid
161
1x
            c.JSON(http.StatusUnauthorized, gin.H{
162
1x
                "error": "Invalid API key",
163
1x
                "code":  "UNAUTHORIZED",
164
1x
            })
165
1x
            c.Abort()
166
1x
            return
167
        }
168

169
        // Fall back to session authentication
170
1x
        session := sessions.Default(c)
171
1x
        userID := session.Get(UserIDKey)
172
1x

173
1x
        if userID == nil {
174
            c.JSON(http.StatusUnauthorized, gin.H{
175
                "error": "Authentication required",
176
                "code":  "UNAUTHORIZED",
177
            })
178
            c.Abort()
179
            return
180
        }
181

182
        // Validate user_id is an integer
183
1x
        userIDInt, ok := userID.(int)
184
1x
        if !ok {
185
            // Try to convert from float64 (JSON numbers are often stored as float64)
186
            if userIDFloat, ok := userID.(float64); ok {
187
                userIDInt = int(userIDFloat)
188
            } else {
189
                c.JSON(http.StatusUnauthorized, gin.H{
190
                    "error": "Authentication required",
191
                    "code":  "UNAUTHORIZED",
192
                })
193
                c.Abort()
194
                return
195
            }
196
        }
197

198
        // Validate username is a string and not empty
199
1x
        username := session.Get(UsernameKey)
200
1x
        if username == nil {
201
            c.JSON(http.StatusUnauthorized, gin.H{
202
                "error": "Authentication required",
203
                "code":  "UNAUTHORIZED",
204
            })
205
            c.Abort()
206
            return
207
        }
208

209
1x
        usernameStr, ok := username.(string)
210
1x
        if !ok || usernameStr == "" {
211
            c.JSON(http.StatusUnauthorized, gin.H{
212
                "error": "Authentication required",
213
                "code":  "UNAUTHORIZED",
214
            })
215
            c.Abort()
216
            return
217
        }
218

219
        // Store user info in context for handlers to use
220
1x
        c.Set(UserIDKey, userIDInt)
221
1x
        c.Set(UsernameKey, usernameStr)
222
1x
        c.Set(AuthMethodKey, AuthMethodSession)
223
1x

224
1x
        c.Next()
225
    }
226
}
227

228
// RequireAdmin returns a middleware that requires authentication and admin role
229
4x
func RequireAdmin(userService interface{}) gin.HandlerFunc {
230
4x
    // Type assertion to get the user service
231
4x
    us, ok := userService.(interface {
232
4x
        IsAdmin(ctx context.Context, userID int) (bool, error)
233
4x
    })
234
4x
    if !ok {
235
        panic("userService must implement IsAdmin method")
236
    }
237

238
4x
    return func(c *gin.Context) {
239
4x
        // First check authentication
240
4x
        session := sessions.Default(c)
241
4x
        userID := session.Get(UserIDKey)
242
4x

243
4x
        if userID == nil {
244
1x
            c.JSON(http.StatusUnauthorized, gin.H{
245
1x
                "error": "Authentication required",
246
1x
                "code":  "UNAUTHORIZED",
247
1x
            })
248
1x
            c.Abort()
249
1x
            return
250
1x
        }
251

252
        // Validate user_id is an integer
253
3x
        userIDInt, ok := userID.(int)
254
3x
        if !ok {
255
            // Try to convert from float64 (JSON numbers are often stored as float64)
256
            if userIDFloat, ok := userID.(float64); ok {
257
                userIDInt = int(userIDFloat)
258
            } else {
259
                c.JSON(http.StatusUnauthorized, gin.H{
260
                    "error": "Authentication required",
261
                    "code":  "UNAUTHORIZED",
262
                })
263
                c.Abort()
264
                return
265
            }
266
        }
267

268
        // Validate username is a string and not empty
269
3x
        username := session.Get(UsernameKey)
270
3x
        if username == nil {
271
            c.JSON(http.StatusUnauthorized, gin.H{
272
                "error": "Authentication required",
273
                "code":  "UNAUTHORIZED",
274
            })
275
            c.Abort()
276
            return
277
        }
278

279
3x
        usernameStr, ok := username.(string)
280
3x
        if !ok || usernameStr == "" {
281
            c.JSON(http.StatusUnauthorized, gin.H{
282
                "error": "Authentication required",
283
                "code":  "UNAUTHORIZED",
284
            })
285
            c.Abort()
286
            return
287
        }
288

289
        // Check if user has admin role
290
3x
        isAdmin, err := us.IsAdmin(c.Request.Context(), userIDInt)
291
3x
        if err != nil {
292
1x
            c.JSON(http.StatusInternalServerError, gin.H{
293
1x
                "error": "Failed to check admin status",
294
1x
                "code":  "INTERNAL_ERROR",
295
1x
            })
296
1x
            c.Abort()
297
1x
            return
298
1x
        }
299

300
2x
        if !isAdmin {
301
1x
            c.JSON(http.StatusForbidden, gin.H{
302
1x
                "error": "Admin access required",
303
1x
                "code":  "FORBIDDEN",
304
1x
            })
305
1x
            c.Abort()
306
1x
            return
307
1x
        }
308

309
        // Store user info in context for handlers to use
310
1x
        c.Set(UserIDKey, userIDInt)
311
1x
        c.Set(UsernameKey, usernameStr)
312
1x

313
1x
        c.Next()
314
    }
315
}
316


			
quizapp internal middleware validation.go
51.6%
Statements
48/93
1
package middleware
2

3
import (
4
    "fmt"
5
    "net/http"
6
    "runtime/debug"
7
    "time"
8

9
    contextutils "quizapp/internal/utils"
10

11
    "github.com/gin-gonic/gin"
12
)
13

14
// ErrorRecoveryConfig configures error recovery behavior
15
type ErrorRecoveryConfig struct {
16
    // MaxRetries specifies the maximum number of retries for retryable errors
17
    MaxRetries int
18
    // RetryDelay specifies the base delay between retries
19
    RetryDelay time.Duration
20
    // MaxRetryDelay specifies the maximum delay between retries
21
    MaxRetryDelay time.Duration
22
    // EnableCircuitBreaker enables circuit breaker pattern
23
    EnableCircuitBreaker bool
24
    // CircuitBreakerThreshold specifies failure threshold for circuit breaker
25
    CircuitBreakerThreshold int
26
    // CircuitBreakerTimeout specifies how long to wait before retrying after circuit opens
27
    CircuitBreakerTimeout time.Duration
28
}
29

30
// DefaultErrorRecoveryConfig returns a default error recovery configuration
31
3x
func DefaultErrorRecoveryConfig() *ErrorRecoveryConfig {
32
3x
    return &ErrorRecoveryConfig{
33
3x
        MaxRetries:              3,
34
3x
        RetryDelay:              100 * time.Millisecond,
35
3x
        MaxRetryDelay:           5 * time.Second,
36
3x
        EnableCircuitBreaker:    false,
37
3x
        CircuitBreakerThreshold: 5,
38
3x
        CircuitBreakerTimeout:   30 * time.Second,
39
3x
    }
40
3x
}
41

42
// circuitBreakerState represents the state of a circuit breaker
43
type circuitBreakerState int
44

45
const (
46
    circuitClosed circuitBreakerState = iota
47
    circuitOpen
48
    circuitHalfOpen
49
)
50

51
// circuitBreaker tracks failures and manages circuit state
52
type circuitBreaker struct {
53
    state       circuitBreakerState
54
    failures    int
55
    lastFailure time.Time
56
    config      *ErrorRecoveryConfig
57
}
58

59
// newCircuitBreaker creates a new circuit breaker
60
1x
func newCircuitBreaker(config *ErrorRecoveryConfig) *circuitBreaker {
61
1x
    return &circuitBreaker{
62
1x
        state:  circuitClosed,
63
1x
        config: config,
64
1x
    }
65
1x
}
66

67
// canExecute checks if the circuit breaker allows execution
68
4x
func (cb *circuitBreaker) canExecute() bool {
69
4x
    switch cb.state {
70
2x
    case circuitClosed:
71
2x
        return true
72
2x
    case circuitOpen:
73
2x
        if time.Since(cb.lastFailure) > cb.config.CircuitBreakerTimeout {
74
1x
            cb.state = circuitHalfOpen
75
1x
            return true
76
1x
        }
77
1x
        return false
78
    case circuitHalfOpen:
79
        return true
80
    default:
81
        return false
82
    }
83
}
84

85
// recordSuccess records a successful execution
86
1x
func (cb *circuitBreaker) recordSuccess() {
87
1x
    cb.failures = 0
88
1x
    cb.state = circuitClosed
89
1x
}
90

91
// recordFailure records a failed execution
92
2x
func (cb *circuitBreaker) recordFailure() {
93
2x
    cb.failures++
94
2x
    cb.lastFailure = time.Now()
95
2x

96
2x
    if cb.failures >= cb.config.CircuitBreakerThreshold {
97
1x
        cb.state = circuitOpen
98
1x
    }
99
}
100

101
// ErrorRecoveryMiddleware creates middleware for handling panics and retrying failed requests
102
2x
func ErrorRecoveryMiddleware(logger interface{}, config *ErrorRecoveryConfig) gin.HandlerFunc {
103
2x
    if config == nil {
104
2x
        config = DefaultErrorRecoveryConfig()
105
2x
    }
106

107
    // Create circuit breaker if enabled
108
2x
    var cb *circuitBreaker
109
2x
    if config.EnableCircuitBreaker {
110
        cb = newCircuitBreaker(config)
111
    }
112

113
2x
    return func(c *gin.Context) {
114
2x
        defer func() {
115
2x
            if err := recover(); err != nil {
116
1x
                // Log the panic with stack trace
117
1x
                stackTrace := string(debug.Stack())
118
1x
                fmt.Printf("Panic recovered: %v\nStack trace: %s\n", err, stackTrace)
119
1x

120
1x
                // Convert panic value to error if needed
121
1x
                var panicErr error
122
1x
                if e, ok := err.(error); ok {
123
                    panicErr = e
124
                } else {
125
1x
                    panicErr = contextutils.WrapErrorf(nil, "panic: %v", err)
126
1x
                }
127

128
                // Send error response
129
1x
                appErr := contextutils.NewAppErrorWithCause(
130
1x
                    contextutils.ErrorCodeInternalError,
131
1x
                    contextutils.SeverityFatal,
132
1x
                    "Internal server error",
133
1x
                    "A panic occurred while processing the request",
134
1x
                    contextutils.WrapError(panicErr, "panic"),
135
1x
                )
136
1x

137
1x
                // Add stack trace to error details in development
138
1x
                if gin.Mode() == gin.DebugMode {
139
                    appErr.Details = fmt.Sprintf("%s\nStack trace: %s", appErr.Details, stackTrace)
140
                }
141

142
1x
                HandleAppError(c, appErr)
143
1x
                c.Abort()
144
            }
145
        }()
146

147
        // Check circuit breaker
148
2x
        if cb != nil && !cb.canExecute() {
149
            ServiceUnavailable(c, "Service temporarily unavailable due to high error rate")
150
            c.Abort()
151
            return
152
        }
153

154
        // Process request
155
2x
        c.Next()
156
2x

157
2x
        // Record success/failure for circuit breaker
158
2x
        if cb != nil {
159
            if c.Writer.Status() >= 500 {
160
                cb.recordFailure()
161
            } else if c.Writer.Status() < 500 && cb.state == circuitHalfOpen {
162
                cb.recordSuccess()
163
            }
164
        }
165

166
        // Retry logic for failed requests
167
1x
        if shouldRetry(c.Writer.Status(), c.Errors) {
168
            retryWithBackoff(c, config, logger)
169
        }
170
    }
171
}
172

173
// shouldRetry determines if a request should be retried
174
6x
func shouldRetry(statusCode int, errors []*gin.Error) bool {
175
6x
    // Only retry 5xx errors and certain 4xx errors
176
6x
    if statusCode >= 500 {
177
1x
        return true
178
1x
    }
179

180
    // Retry on specific 4xx errors that might be transient
181
5x
    if statusCode == http.StatusRequestTimeout || statusCode == http.StatusTooManyRequests {
182
2x
        return true
183
2x
    }
184

185
    // Check if there are errors that indicate retryable failures
186
3x
    for _, err := range errors {
187
        if contextutils.IsRetryable(err) {
188
            return true
189
        }
190
    }
191

192
3x
    return false
193
}
194

195
// retryWithBackoff attempts to retry the request with exponential backoff
196
func retryWithBackoff(c *gin.Context, config *ErrorRecoveryConfig, logger interface{}) {
197
    // Only retry idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE)
198
    method := c.Request.Method
199
    if method != http.MethodGet && method != http.MethodHead &&
200
        method != http.MethodOptions && method != http.MethodPut &&
201
        method != http.MethodDelete {
202
        return
203
    }
204

205
    // Get the original handler
206
    handlerName := c.HandlerName()
207
    if handlerName == "" {
208
        return
209
    }
210

211
    // Calculate retry delay with exponential backoff
212
    delay := config.RetryDelay
213
    for i := 0; i < config.MaxRetries; i++ {
214
        time.Sleep(delay)
215

216
        // Double the delay for next iteration (with max limit)
217
        delay *= 2
218
        if delay > config.MaxRetryDelay {
219
            delay = config.MaxRetryDelay
220
        }
221

222
        // Log retry attempt
223
        if logger != nil {
224
            // This would be logged using the observability logger in real implementation
225
            fmt.Printf("Retrying request %s %s (attempt %d/%d)\n",
226
                method, c.Request.URL.Path, i+1, config.MaxRetries)
227
        }
228

229
        // Note: In a real implementation, we would need to recreate the request
230
        // and re-execute it. This is a simplified version for demonstration.
231
        // The actual retry logic would depend on the specific use case.
232
    }
233
}
234

235
// HandleAppError handles any AppError and sends appropriate HTTP response
236
1x
func HandleAppError(c *gin.Context, err error) {
237
1x
    if appErr, ok := err.(*contextutils.AppError); ok {
238
1x
        StandardizeAppError(c, appErr)
239
1x
    } else {
240
        // Fallback for non-AppError types
241
        StandardizeHTTPError(c, http.StatusInternalServerError, "Internal server error", err.Error())
242
    }
243
}
244

245
// StandardizeAppError sends a structured error response using AppError
246
1x
func StandardizeAppError(c *gin.Context, err *contextutils.AppError) {
247
1x
    // Map error codes to HTTP status codes
248
1x
    statusCode := mapErrorCodeToHTTPStatus(err.Code)
249
1x

250
1x
    // Convert error to JSON structure
251
1x
    errorJSON := err.ToJSON()
252
1x

253
1x
    // Add retryable information based on error type
254
1x
    errorJSON["retryable"] = contextutils.IsRetryable(err)
255
1x

256
1x
    c.JSON(statusCode, errorJSON)
257
1x
}
258

259
// StandardizeHTTPError creates consistent HTTP error responses with structured error information
260
func StandardizeHTTPError(c *gin.Context, _ int, message, details string) {
261
    // Create a generic AppError for consistent response format
262
    appErr := contextutils.NewAppError(
263
        contextutils.ErrorCodeInternalError,
264
        contextutils.SeverityError,
265
        message,
266
        details,
267
    )
268

269
    StandardizeAppError(c, appErr)
270
}
271

272
// ServiceUnavailable sends a 503 Service Unavailable error with a standardized payload
273
func ServiceUnavailable(c *gin.Context, msg string) {
274
    appErr := contextutils.NewAppError(
275
        contextutils.ErrorCodeServiceUnavailable,
276
        contextutils.SeverityError,
277
        msg,
278
        "",
279
    )
280
    StandardizeAppError(c, appErr)
281
}
282

283
// mapErrorCodeToHTTPStatus maps AppError codes to appropriate HTTP status codes
284
1x
func mapErrorCodeToHTTPStatus(code contextutils.ErrorCode) int {
285
1x
    switch code {
286
    // 4xx Client Errors
287
    case contextutils.ErrorCodeInvalidInput, contextutils.ErrorCodeMissingRequired,
288
        contextutils.ErrorCodeInvalidFormat, contextutils.ErrorCodeValidationFailed,
289
        contextutils.ErrorCodeOAuthStateMismatch:
290
        return http.StatusBadRequest
291

292
    case contextutils.ErrorCodeUnauthorized:
293
        return http.StatusUnauthorized
294

295
    case contextutils.ErrorCodeForbidden:
296
        return http.StatusForbidden
297

298
    case contextutils.ErrorCodeRecordNotFound, contextutils.ErrorCodeQuestionNotFound,
299
        contextutils.ErrorCodeAssignmentNotFound:
300
        return http.StatusNotFound
301

302
    case contextutils.ErrorCodeRecordExists:
303
        return http.StatusConflict
304

305
    case contextutils.ErrorCodeSessionExpired, contextutils.ErrorCodeInvalidCredentials:
306
        return http.StatusUnauthorized
307

308
    case contextutils.ErrorCodeRateLimit:
309
        return http.StatusTooManyRequests
310

311
    // 5xx Server Errors
312
1x
    case contextutils.ErrorCodeInternalError:
313
1x
        return http.StatusInternalServerError
314

315
    case contextutils.ErrorCodeServiceUnavailable, contextutils.ErrorCodeDatabaseConnection,
316
        contextutils.ErrorCodeAIProviderUnavailable:
317
        return http.StatusServiceUnavailable
318

319
    case contextutils.ErrorCodeTimeout:
320
        return http.StatusRequestTimeout
321

322
    case contextutils.ErrorCodeDatabaseQuery, contextutils.ErrorCodeDatabaseTransaction,
323
        contextutils.ErrorCodeForeignKeyViolation, contextutils.ErrorCodeTimestampMissingTimezone,
324
        contextutils.ErrorCodeAIRequestFailed, contextutils.ErrorCodeAIResponseInvalid,
325
        contextutils.ErrorCodeAIConfigInvalid, contextutils.ErrorCodeOAuthProviderError:
326
        return http.StatusInternalServerError
327

328
    // Default to internal server error for unknown codes
329
    default:
330
        return http.StatusInternalServerError
331
    }
332
}
333


			
quizapp internal middleware validation.go
52.5%
Statements
220/419
1
package middleware
2

3
import (
4
    "encoding/json"
5
    "fmt"
6
    "os"
7
    "strings"
8

9
    contextutils "quizapp/internal/utils"
10

11
    "github.com/xeipuuv/gojsonschema"
12
    "gopkg.in/yaml.v2"
13
)
14

15
// SchemaLoader loads JSON schemas from the Swagger specification
16
type SchemaLoader struct {
17
    schemas               map[string]*gojsonschema.Schema
18
    jsonCompatibleSchemas map[string]interface{}
19
    swaggerData           map[string]interface{}
20
}
21

22
// NewSchemaLoader creates a new schema loader
23
1x
func NewSchemaLoader() *SchemaLoader {
24
1x
    return &SchemaLoader{
25
1x
        schemas:               make(map[string]*gojsonschema.Schema),
26
1x
        jsonCompatibleSchemas: make(map[string]interface{}),
27
1x
    }
28
1x
}
29

30
// LoadSchemasFromSwagger loads all schemas from the Swagger specification
31
func (sl *SchemaLoader) LoadSchemasFromSwagger(swaggerPath string) error {
32
    // Read the Swagger file
33
    data, err := os.ReadFile(swaggerPath)
34
    if err != nil {
35
        return contextutils.WrapError(err, "failed to read swagger file")
36
    }
37

38
    return sl.LoadSchemasFromSwaggerFromData(data)
39
}
40

41
// LoadSchemasFromSwaggerFromData loads all schemas from swagger data bytes
42
1x
func (sl *SchemaLoader) LoadSchemasFromSwaggerFromData(data []byte) error {
43
1x
    // Parse the Swagger spec (YAML only)
44
1x
    var swagger map[string]interface{}
45
1x

46
1x
    if err := yaml.Unmarshal(data, &swagger); err != nil {
47
        return contextutils.WrapError(err, "failed to parse swagger file as YAML")
48
    }
49

50
1x
    fmt.Printf("â Successfully parsed swagger file as YAML\n")
51
1x

52
1x
    // Store the parsed swagger data for later use
53
1x
    sl.swaggerData = swagger
54
1x

55
1x
    // Extract components/schemas
56
1x
    components, ok := swagger["components"].(map[interface{}]interface{})
57
1x
    if !ok {
58
        fmt.Printf("â No components section found. Available keys: %v\n", getKeys(swagger))
59
        fmt.Printf("â Components type: %T, value: %v\n", swagger["components"], swagger["components"])
60
        return contextutils.ErrorWithContextf("no components section found in swagger")
61
    }
62

63
1x
    schemas, ok := components["schemas"].(map[interface{}]interface{})
64
1x
    if !ok {
65
        fmt.Printf("â No schemas section found in components. Available keys: %v\n", getKeysInterface(components))
66
        fmt.Printf("â Schemas type: %T, value: %v\n", components["schemas"], components["schemas"])
67
        return contextutils.ErrorWithContextf("no schemas section found in swagger")
68
    }
69

70
    // Convert schemas to JSON-compatible format
71
1x
    jsonCompatibleSchemas := make(map[string]interface{})
72
1x
    for schemaName, schemaData := range schemas {
73
145x
        schemaNameStr, ok := schemaName.(string)
74
145x
        if !ok {
75
            fmt.Printf("Warning: schema name is not a string: %v\n", schemaName)
76
            continue
77
        }
78

79
145x
        convertedSchema := convertToJSONCompatible(schemaData)
80
145x

81
145x
        jsonCompatibleSchemas[schemaNameStr] = convertedSchema
82
    }
83

84
    // Store jsonCompatibleSchemas for creating array schemas later
85
1x
    sl.jsonCompatibleSchemas = jsonCompatibleSchemas
86
1x

87
1x
    // Load each schema
88
1x
    for schemaNameStr := range jsonCompatibleSchemas {
89
145x
        // Create a schema document with the full swagger context for $ref resolution
90
145x
        completeSchemaDoc := map[string]interface{}{
91
145x
            "$schema": "http://json-schema.org/draft-07/schema#",
92
145x
            "components": map[string]interface{}{
93
145x
                "schemas": jsonCompatibleSchemas,
94
145x
            },
95
145x
            "$ref": "#/components/schemas/" + schemaNameStr,
96
145x
        }
97
145x

98
145x
        schemaBytes, err := json.Marshal(completeSchemaDoc)
99
145x
        if err != nil {
100
            fmt.Printf("Warning: failed to marshal schema %s: %v\n", schemaNameStr, err)
101
            continue
102
        }
103

104
        // Load the schema
105
145x
        schemaLoader := gojsonschema.NewBytesLoader(schemaBytes)
106
145x
        schema, err := gojsonschema.NewSchema(schemaLoader)
107
145x
        if err != nil {
108
            fmt.Printf("Warning: failed to load schema %s: %v\n", schemaNameStr, err)
109
            continue
110
        }
111

112
145x
        sl.schemas[schemaNameStr] = schema
113
145x
        fmt.Printf("â Loaded schema: %s\n", schemaNameStr)
114
    }
115

116
1x
    return nil
117
}
118

119
// getKeys returns the keys of a map
120
func getKeys(m map[string]interface{}) []string {
121
    keys := make([]string, 0, len(m))
122
    for k := range m {
123
        keys = append(keys, k)
124
    }
125
    return keys
126
}
127

128
// getKeysInterface returns the keys of a map with interface{} keys
129
func getKeysInterface(m map[interface{}]interface{}) []string {
130
    keys := make([]string, 0, len(m))
131
    for k := range m {
132
        if keyStr, ok := k.(string); ok {
133
            keys = append(keys, keyStr)
134
        }
135
    }
136
    return keys
137
}
138

139
// convertInterfaceMapToStringMap converts a map[interface{}]interface{} to map[string]interface{}
140
491x
func convertInterfaceMapToStringMap(m map[interface{}]interface{}) map[string]interface{} {
141
491x
    result := make(map[string]interface{})
142
491x
    for k, v := range m {
143
64812x
        keyStr := fmt.Sprintf("%v", k) // Convert any key type to string
144
64812x
        result[keyStr] = convertToJSONCompatible(v)
145
64812x
    }
146
491x
    return result
147
}
148

149
// convertToJSONCompatible converts a map[interface{}]interface{} to map[string]interface{}
150
3012114x
func convertToJSONCompatible(data interface{}) interface{} {
151
3012114x
    switch v := data.(type) {
152
1488798x
    case map[interface{}]interface{}:
153
1488798x
        result := make(map[string]interface{})
154
1488798x
        hasNullable := false
155
1488798x

156
1488798x
        for k, val := range v {
157
2697490x
            keyStr := fmt.Sprintf("%v", k) // Convert any key type to string
158
2697490x

159
2697490x
            // Check for nullable field
160
2697490x
            if keyStr == "nullable" {
161
73x
                nullable, ok := val.(bool)
162
73x
                if ok && nullable {
163
73x
                    hasNullable = true
164
73x
                    continue // Skip the nullable field as we'll handle it in the type conversion
165
                }
166
            }
167

168
2697417x
            convertedVal := convertToJSONCompatible(val)
169
2697417x
            result[keyStr] = convertedVal
170
        }
171

172
        // Handle nullable fields by converting to union type
173
1488798x
        if hasNullable {
174
73x
            // If there's a $ref field, create a union type with null
175
73x
            if ref, hasRef := result["$ref"].(string); hasRef {
176
1x
                // Create a union type that allows both the referenced type and null
177
1x
                result["oneOf"] = []interface{}{
178
1x
                    map[string]interface{}{"$ref": ref},
179
1x
                    map[string]interface{}{"enum": []interface{}{nil}},
180
1x
                }
181
1x
                // Remove the original $ref field
182
1x
                delete(result, "$ref")
183
1x
            } else if typeVal, hasType := result["type"].(string); hasType {
184
                // If there's a type field, convert to array of types including null
185
72x
                result["type"] = []interface{}{typeVal, "null"}
186
72x
            }
187
        }
188

189
1488798x
        return result
190
265263x
    case []interface{}:
191
265263x
        result := make([]interface{}, len(v))
192
265263x
        for i, val := range v {
193
249740x
            convertedVal := convertToJSONCompatible(val)
194
249740x
            result[i] = convertedVal
195
249740x
        }
196
265263x
        return result
197
1258053x
    default:
198
1258053x
        return data
199
    }
200
}
201

202
// ValidateData validates data against a schema
203
3x
func (sl *SchemaLoader) ValidateData(data interface{}, schemaName string) error {
204
3x
    schema, exists := sl.schemas[schemaName]
205
3x
    if !exists {
206
        return contextutils.ErrorWithContextf("schema %s not found", schemaName)
207
    }
208

209
    // Convert data to JSON
210
3x
    jsonData, err := json.Marshal(data)
211
3x
    if err != nil {
212
        return contextutils.WrapError(err, "failed to marshal data")
213
    }
214

215
    // Create document loader
216
3x
    documentLoader := gojsonschema.NewBytesLoader(jsonData)
217
3x

218
3x
    // Validate
219
3x
    result, err := schema.Validate(documentLoader)
220
3x
    if err != nil {
221
        return contextutils.WrapError(err, "validation error")
222
    }
223

224
3x
    if !result.Valid() {
225
1x
        var validationErrors []string
226
1x
        for _, validationErr := range result.Errors() {
227
3x
            errorMsg := fmt.Sprintf("%s: %s", validationErr.Field(), validationErr.Description())
228
3x
            // Include the actual value that failed validation if available
229
3x
            if validationErr.Value() != nil {
230
3x
                errorMsg += fmt.Sprintf(" (received: %v)", validationErr.Value())
231
3x
            }
232
3x
            validationErrors = append(validationErrors, errorMsg)
233
        }
234
1x
        return contextutils.ErrorWithContextf("schema validation failed: %s", strings.Join(validationErrors, "; "))
235
    }
236

237
2x
    return nil
238
}
239

240
// AutoLoadSchemas automatically loads schemas from the swagger file path
241
func AutoLoadSchemas() *SchemaLoader {
242
    loader := NewSchemaLoader()
243

244
    // Get swagger file path from environment variable
245
    swaggerPath := os.Getenv("SWAGGER_FILE_PATH")
246
    if swaggerPath == "" {
247
        fmt.Printf("â SWAGGER_FILE_PATH environment variable not set\n")
248
        return loader
249
    }
250

251
    if _, err := os.Stat(swaggerPath); err == nil {
252
        if err := loader.LoadSchemasFromSwagger(swaggerPath); err != nil {
253
            fmt.Printf("Warning: failed to load schemas from %s: %v\n", swaggerPath, err)
254
        } else {
255
            fmt.Printf("â Successfully loaded schemas from %s\n", swaggerPath)
256
            return loader
257
        }
258
    } else {
259
        fmt.Printf("âï  Swagger file not found at %s: %v\n", swaggerPath, err)
260
    }
261

262
    return loader
263
}
264

265
// IsEndpointDocumented checks if an endpoint is documented in the swagger spec
266
164x
func (sl *SchemaLoader) IsEndpointDocumented(path, method string) bool {
267
164x
    // Use cached swagger data if available
268
164x
    if sl.swaggerData == nil {
269
        return false
270
    }
271
164x
    swagger := sl.swaggerData
272
164x

273
164x
    // Extract paths
274
164x
    paths, ok := swagger["paths"].(map[string]interface{})
275
164x
    if !ok {
276
164x
        // Try with interface{} keys
277
164x
        pathsInterface, ok := swagger["paths"].(map[interface{}]interface{})
278
164x
        if !ok {
279
            return false
280
        }
281
        // Convert to string keys
282
164x
        paths = convertInterfaceMapToStringMap(pathsInterface)
283
    }
284

285
    // First, try exact match
286
164x
    pathInfo, exists := paths[path]
287
164x
    if exists {
288
158x
        pathMap, ok := pathInfo.(map[string]interface{})
289
158x
        if !ok {
290
            // Try with interface{} keys
291
            pathMapInterface, ok := pathInfo.(map[interface{}]interface{})
292
            if !ok {
293
                return false
294
            }
295
            // Convert to string keys
296
            pathMap = convertInterfaceMapToStringMap(pathMapInterface)
297
        }
298

299
        // Look for the specific HTTP method
300
158x
        _, exists = pathMap[strings.ToLower(method)]
301
158x
        if exists {
302
158x
            return true
303
158x
        }
304
    }
305

306
    // If exact match fails, try pattern matching for path parameters
307
6x
    for swaggerPath := range paths {
308
983x
        if sl.pathMatchesPattern(path, swaggerPath) {
309
5x
            pathInfo := paths[swaggerPath]
310
5x
            pathMap, ok := pathInfo.(map[string]interface{})
311
5x
            if !ok {
312
                // Try with interface{} keys
313
                pathMapInterface, ok := pathInfo.(map[interface{}]interface{})
314
                if !ok {
315
                    continue
316
                }
317
                // Convert to string keys
318
                pathMap = convertInterfaceMapToStringMap(pathMapInterface)
319
            }
320

321
            // Look for the specific HTTP method
322
5x
            _, exists = pathMap[strings.ToLower(method)]
323
5x
            if exists {
324
5x
                return true
325
5x
            }
326
        }
327
    }
328

329
1x
    return false
330
}
331

332
// pathMatchesPattern checks if a request path matches a swagger path pattern
333
2187x
func (sl *SchemaLoader) pathMatchesPattern(requestPath, swaggerPath string) bool {
334
2187x
    // Split paths into segments
335
2187x
    requestSegments := strings.Split(requestPath, "/")
336
2187x
    swaggerSegments := strings.Split(swaggerPath, "/")
337
2187x

338
2187x
    // Paths must have the same number of segments
339
2187x
    if len(requestSegments) != len(swaggerSegments) {
340
1535x
        return false
341
1535x
    }
342

343
    // Compare each segment
344
652x
    for i, swaggerSegment := range swaggerSegments {
345
2035x
        requestSegment := requestSegments[i]
346
2035x

347
2035x
        // If swagger segment is a parameter (starts with { and ends with })
348
2035x
        if strings.HasPrefix(swaggerSegment, "{") && strings.HasSuffix(swaggerSegment, "}") {
349
15x
            // Any value is acceptable for parameters
350
15x
            continue
351
        }
352

353
        // Otherwise, segments must match exactly
354
2005x
        if swaggerSegment != requestSegment {
355
622x
            return false
356
622x
        }
357
    }
358

359
15x
    return true
360
}
361

362
// DetermineRequestSchemaFromPath automatically determines the schema name from the API path and method
363
163x
func (sl *SchemaLoader) DetermineRequestSchemaFromPath(path, method string) string {
364
163x
    // Use cached swagger data if available
365
163x
    if sl.swaggerData == nil {
366
        return ""
367
    }
368
163x
    swagger := sl.swaggerData
369
163x

370
163x
    // Extract paths
371
163x
    paths, ok := swagger["paths"].(map[string]interface{})
372
163x
    if !ok {
373
163x
        // Try with interface{} keys
374
163x
        pathsInterface, ok := swagger["paths"].(map[interface{}]interface{})
375
163x
        if !ok {
376
            return ""
377
        }
378
        // Convert to string keys
379
163x
        paths = convertInterfaceMapToStringMap(pathsInterface)
380
    }
381

382
    // First, try exact match
383
163x
    pathInfo, exists := paths[path]
384
163x
    if !exists {
385
5x
        // If exact match fails, try pattern matching for path parameters
386
5x
        for swaggerPath := range paths {
387
649x
            if sl.pathMatchesPattern(path, swaggerPath) {
388
5x
                pathInfo = paths[swaggerPath]
389
5x
                break
390
            }
391
        }
392
5x
        if pathInfo == nil {
393
            return ""
394
        }
395
    }
396

397
163x
    pathMap, ok := pathInfo.(map[string]interface{})
398
163x
    if !ok {
399
        // Try with interface{} keys
400
        pathMapInterface, ok := pathInfo.(map[interface{}]interface{})
401
        if !ok {
402
            return ""
403
        }
404
        // Convert to string keys
405
        pathMap = convertInterfaceMapToStringMap(pathMapInterface)
406
    }
407

408
    // Look for the specific HTTP method
409
163x
    methodInfo, exists := pathMap[strings.ToLower(method)]
410
163x
    if !exists {
411
        return ""
412
    }
413

414
163x
    methodMap, ok := methodInfo.(map[string]interface{})
415
163x
    if !ok {
416
        // Try with interface{} keys
417
        methodMapInterface, ok := methodInfo.(map[interface{}]interface{})
418
        if !ok {
419
            return ""
420
        }
421
        // Convert to string keys
422
        methodMap = convertInterfaceMapToStringMap(methodMapInterface)
423
    }
424

425
    // Extract the request body schema
426
163x
    requestBody, exists := methodMap["requestBody"]
427
163x
    if !exists {
428
120x
        return ""
429
120x
    }
430

431
43x
    requestBodyMap, ok := requestBody.(map[string]interface{})
432
43x
    if !ok {
433
        // Try with interface{} keys
434
        requestBodyMapInterface, ok := requestBody.(map[interface{}]interface{})
435
        if !ok {
436
            return ""
437
        }
438
        // Convert to string keys
439
        requestBodyMap = convertInterfaceMapToStringMap(requestBodyMapInterface)
440
    }
441

442
    // Extract content
443
43x
    content, ok := requestBodyMap["content"].(map[string]interface{})
444
43x
    if !ok {
445
        // Try with interface{} keys
446
        contentInterface, ok := requestBodyMap["content"].(map[interface{}]interface{})
447
        if !ok {
448
            return ""
449
        }
450
        // Convert to string keys
451
        content = convertInterfaceMapToStringMap(contentInterface)
452
    }
453

454
    // Look for application/json content
455
43x
    jsonContent, exists := content["application/json"]
456
43x
    if !exists {
457
        return ""
458
    }
459

460
43x
    jsonContentMap, ok := jsonContent.(map[string]interface{})
461
43x
    if !ok {
462
        // Try with interface{} keys
463
        jsonContentMapInterface, ok := jsonContent.(map[interface{}]interface{})
464
        if !ok {
465
            return ""
466
        }
467
        // Convert to string keys
468
        jsonContentMap = convertInterfaceMapToStringMap(jsonContentMapInterface)
469
    }
470

471
    // Extract schema
472
43x
    schema, exists := jsonContentMap["schema"]
473
43x
    if !exists {
474
        return ""
475
    }
476

477
43x
    schemaMap, ok := schema.(map[string]interface{})
478
43x
    if !ok {
479
        // Try with interface{} keys
480
        schemaMapInterface, ok := schema.(map[interface{}]interface{})
481
        if !ok {
482
            return ""
483
        }
484
        // Convert to string keys
485
        schemaMap = convertInterfaceMapToStringMap(schemaMapInterface)
486
    }
487

488
    // Extract $ref
489
43x
    ref, exists := schemaMap["$ref"]
490
43x
    if !exists {
491
9x
        return ""
492
9x
    }
493

494
34x
    refStr, ok := ref.(string)
495
34x
    if !ok {
496
        return ""
497
    }
498

499
    // Extract schema name from $ref
500
    // $ref format: "#/components/schemas/SchemaName"
501
34x
    parts := strings.Split(refStr, "/")
502
34x
    if len(parts) < 4 {
503
        return ""
504
    }
505

506
34x
    return parts[len(parts)-1]
507
}
508

509
// DetermineSchemaFromPath determines the schema name for a given path and HTTP method
510
// by parsing the swagger file and looking up the response schema for the 200 status code.
511
163x
func (sl *SchemaLoader) DetermineSchemaFromPath(path, method string) string {
512
163x
    // Use cached swagger data if available
513
163x
    if sl.swaggerData == nil {
514
        return ""
515
    }
516
163x
    swagger := sl.swaggerData
517
163x

518
163x
    // Extract paths
519
163x
    paths, ok := swagger["paths"].(map[string]interface{})
520
163x
    if !ok {
521
163x
        // Try with interface{} keys
522
163x
        pathsInterface, ok := swagger["paths"].(map[interface{}]interface{})
523
163x
        if !ok {
524
            return ""
525
        }
526
        // Convert to string keys
527
163x
        paths = convertInterfaceMapToStringMap(pathsInterface)
528
    }
529

530
    // First, try exact match
531
163x
    pathInfo, exists := paths[path]
532
163x
    if !exists {
533
5x
        // If exact match fails, try pattern matching for path parameters
534
5x
        for swaggerPath := range paths {
535
555x
            if sl.pathMatchesPattern(path, swaggerPath) {
536
5x
                pathInfo = paths[swaggerPath]
537
5x
                break
538
            }
539
        }
540
5x
        if pathInfo == nil {
541
            return ""
542
        }
543
    }
544

545
163x
    pathMap, ok := pathInfo.(map[string]interface{})
546
163x
    if !ok {
547
        // Try with interface{} keys
548
        pathMapInterface, ok := pathInfo.(map[interface{}]interface{})
549
        if !ok {
550
            return ""
551
        }
552
        // Convert to string keys
553
        pathMap = convertInterfaceMapToStringMap(pathMapInterface)
554
    }
555

556
    // Look for the specific HTTP method
557
163x
    methodInfo, exists := pathMap[strings.ToLower(method)]
558
163x
    if !exists {
559
        return ""
560
    }
561

562
163x
    methodMap, ok := methodInfo.(map[string]interface{})
563
163x
    if !ok {
564
        // Try with interface{} keys
565
        methodMapInterface, ok := methodInfo.(map[interface{}]interface{})
566
        if !ok {
567
            return ""
568
        }
569
        // Convert to string keys
570
        methodMap = convertInterfaceMapToStringMap(methodMapInterface)
571
    }
572

573
    // Extract the response schema
574
163x
    responses, ok := methodMap["responses"].(map[string]interface{})
575
163x
    if !ok {
576
        // Try with interface{} keys
577
        responsesInterface, ok := methodMap["responses"].(map[interface{}]interface{})
578
        if !ok {
579
            return ""
580
        }
581
        // Convert to string keys
582
        responses = convertInterfaceMapToStringMap(responsesInterface)
583
    }
584

585
    // Look for success response (try 200, 201, etc.)
586
163x
    var successResponse interface{}
587
163x

588
163x
    // Try common success status codes in order of preference
589
163x
    successCodes := []string{"200", "201", "202"}
590
163x
    for _, code := range successCodes {
591
183x
        if resp, exists := responses[code]; exists {
592
158x
            successResponse = resp
593
158x
            break
594
        }
595
    }
596

597
163x
    if successResponse == nil {
598
5x
        return ""
599
5x
    }
600

601
158x
    responseMap, ok := successResponse.(map[string]interface{})
602
158x
    if !ok {
603
        // Try with interface{} keys
604
        responseMapInterface, ok := successResponse.(map[interface{}]interface{})
605
        if !ok {
606
            return ""
607
        }
608
        // Convert to string keys
609
        responseMap = convertInterfaceMapToStringMap(responseMapInterface)
610
    }
611

612
    // Extract content
613
158x
    content, ok := responseMap["content"].(map[string]interface{})
614
158x
    if !ok {
615
4x
        // Try with interface{} keys
616
4x
        contentInterface, ok := responseMap["content"].(map[interface{}]interface{})
617
4x
        if !ok {
618
4x
            return ""
619
4x
        }
620
        // Convert to string keys
621
        content = convertInterfaceMapToStringMap(contentInterface)
622
    }
623

624
    // Look for application/json
625
154x
    jsonContent, exists := content["application/json"]
626
154x
    if !exists {
627
7x
        return ""
628
7x
    }
629

630
147x
    jsonMap, ok := jsonContent.(map[string]interface{})
631
147x
    if !ok {
632
        // Try with interface{} keys
633
        jsonMapInterface, ok := jsonContent.(map[interface{}]interface{})
634
        if !ok {
635
            return ""
636
        }
637
        // Convert to string keys
638
        jsonMap = convertInterfaceMapToStringMap(jsonMapInterface)
639
    }
640

641
    // Extract schema reference
642
147x
    schema, exists := jsonMap["schema"]
643
147x
    if !exists {
644
        return ""
645
    }
646

647
147x
    schemaMap, ok := schema.(map[string]interface{})
648
147x
    if !ok {
649
        // Try with interface{} keys
650
        schemaMapInterface, ok := schema.(map[interface{}]interface{})
651
        if !ok {
652
            return ""
653
        }
654
        // Convert to string keys
655
        schemaMap = convertInterfaceMapToStringMap(schemaMapInterface)
656
    }
657

658
    // Extract $ref directly
659
147x
    if ref, exists := schemaMap["$ref"]; exists {
660
142x
        if refStr, ok := ref.(string); ok {
661
142x
            // Extract schema name from $ref (e.g., "#/components/schemas/DashboardResponse")
662
142x
            if strings.HasPrefix(refStr, "#/components/schemas/") {
663
142x
                schemaName := strings.TrimPrefix(refStr, "#/components/schemas/")
664
142x
                return schemaName
665
142x
            }
666
        }
667
    }
668

669
    // Handle array schemas - check if it's an array with items that have a $ref
670
5x
    if schemaType, exists := schemaMap["type"]; exists {
671
5x
        if typeStr, ok := schemaType.(string); ok && typeStr == "array" {
672
5x
            // Check for items.$ref
673
5x
            if items, exists := schemaMap["items"]; exists {
674
5x
                itemsMap, ok := items.(map[string]interface{})
675
5x
                if !ok {
676
                    // Try with interface{} keys
677
                    itemsMapInterface, ok := items.(map[interface{}]interface{})
678
                    if !ok {
679
                        return ""
680
                    }
681
                    itemsMap = convertInterfaceMapToStringMap(itemsMapInterface)
682
                }
683

684
5x
                if ref, exists := itemsMap["$ref"]; exists {
685
5x
                    if refStr, ok := ref.(string); ok {
686
5x
                        // Extract schema name from $ref (e.g., "#/components/schemas/Story")
687
5x
                        if strings.HasPrefix(refStr, "#/components/schemas/") {
688
5x
                            itemSchemaName := strings.TrimPrefix(refStr, "#/components/schemas/")
689
5x

690
5x
                            // For array responses, we need to create a synthetic schema that validates arrays
691
5x
                            arraySchemaName := fmt.Sprintf("%sArray", itemSchemaName)
692
5x

693
5x
                            // Check if we've already created this array schema
694
5x
                            if _, exists := sl.schemas[arraySchemaName]; !exists {
695
4x
                                // Create array schema with full context for $ref resolution
696
4x
                                arraySchema := map[string]interface{}{
697
4x
                                    "$schema": "http://json-schema.org/draft-07/schema#",
698
4x
                                    "components": map[string]interface{}{
699
4x
                                        "schemas": sl.jsonCompatibleSchemas,
700
4x
                                    },
701
4x
                                    "type": "array",
702
4x
                                    "items": map[string]interface{}{
703
4x
                                        "$ref": fmt.Sprintf("#/components/schemas/%s", itemSchemaName),
704
4x
                                    },
705
4x
                                }
706
4x

707
4x
                                // Load the array schema
708
4x
                                schemaBytes, err := json.Marshal(arraySchema)
709
4x
                                if err != nil {
710
                                    fmt.Printf("Warning: failed to marshal array schema %s: %v\n", arraySchemaName, err)
711
                                    return itemSchemaName // Fallback to item schema
712
                                }
713

714
4x
                                schemaLoader := gojsonschema.NewBytesLoader(schemaBytes)
715
4x
                                schema, err := gojsonschema.NewSchema(schemaLoader)
716
4x
                                if err != nil {
717
                                    fmt.Printf("Warning: failed to load array schema %s: %v\n", arraySchemaName, err)
718
                                    return itemSchemaName // Fallback to item schema
719
                                }
720

721
4x
                                sl.schemas[arraySchemaName] = schema
722
4x
                                fmt.Printf("â Created array schema: %s\n", arraySchemaName)
723
                            }
724

725
5x
                            return arraySchemaName
726
                        }
727
                    }
728
                }
729
            }
730
        }
731
    }
732

733
    return ""
734
}
735

736
// DetermineResponseSchemaFromPath determines the schema name for a given path, method, and HTTP status code
737
func (sl *SchemaLoader) DetermineResponseSchemaFromPath(path, method, statusCode string) string {
738
    // Use cached swagger data if available
739
    if sl.swaggerData == nil {
740
        return ""
741
    }
742
    swagger := sl.swaggerData
743

744
    // Extract paths
745
    paths, ok := swagger["paths"].(map[string]interface{})
746
    if !ok {
747
        // Try with interface{} keys
748
        pathsInterface, ok := swagger["paths"].(map[interface{}]interface{})
749
        if !ok {
750
            return ""
751
        }
752
        // Convert to string keys
753
        paths = convertInterfaceMapToStringMap(pathsInterface)
754
    }
755

756
    // First, try exact match
757
    pathInfo, exists := paths[path]
758
    if !exists {
759
        // If exact match fails, try pattern matching for path parameters
760
        for swaggerPath := range paths {
761
            if sl.pathMatchesPattern(path, swaggerPath) {
762
                pathInfo = paths[swaggerPath]
763
                break
764
            }
765
        }
766
        if pathInfo == nil {
767
            return ""
768
        }
769
    }
770

771
    pathMap, ok := pathInfo.(map[string]interface{})
772
    if !ok {
773
        // Try with interface{} keys
774
        pathMapInterface, ok := pathInfo.(map[interface{}]interface{})
775
        if !ok {
776
            return ""
777
        }
778
        // Convert to string keys
779
        pathMap = convertInterfaceMapToStringMap(pathMapInterface)
780
    }
781

782
    // Look for the specific HTTP method
783
    methodInfo, exists := pathMap[strings.ToLower(method)]
784
    if !exists {
785
        return ""
786
    }
787

788
    methodMap, ok := methodInfo.(map[string]interface{})
789
    if !ok {
790
        // Try with interface{} keys
791
        methodMapInterface, ok := methodInfo.(map[interface{}]interface{})
792
        if !ok {
793
            return ""
794
        }
795
        // Convert to string keys
796
        methodMap = convertInterfaceMapToStringMap(methodMapInterface)
797
    }
798

799
    // Extract the response schema map
800
    responses, ok := methodMap["responses"].(map[string]interface{})
801
    if !ok {
802
        // Try with interface{} keys
803
        responsesInterface, ok := methodMap["responses"].(map[interface{}]interface{})
804
        if !ok {
805
            return ""
806
        }
807
        // Convert to string keys
808
        responses = convertInterfaceMapToStringMap(responsesInterface)
809
    }
810

811
    // Get response for the exact status code
812
    successResponse, exists := responses[statusCode]
813
    if !exists {
814
        return ""
815
    }
816

817
    responseMap, ok := successResponse.(map[string]interface{})
818
    if !ok {
819
        // Try with interface{} keys
820
        responseMapInterface, ok := successResponse.(map[interface{}]interface{})
821
        if !ok {
822
            return ""
823
        }
824
        // Convert to string keys
825
        responseMap = convertInterfaceMapToStringMap(responseMapInterface)
826
    }
827

828
    // Extract content
829
    content, ok := responseMap["content"].(map[string]interface{})
830
    if !ok {
831
        // Try with interface{} keys
832
        contentInterface, ok := responseMap["content"].(map[interface{}]interface{})
833
        if !ok {
834
            return ""
835
        }
836
        // Convert to string keys
837
        content = convertInterfaceMapToStringMap(contentInterface)
838
    }
839

840
    // Look for application/json
841
    jsonContent, exists := content["application/json"]
842
    if !exists {
843
        return ""
844
    }
845

846
    jsonMap, ok := jsonContent.(map[string]interface{})
847
    if !ok {
848
        // Try with interface{} keys
849
        jsonMapInterface, ok := jsonContent.(map[interface{}]interface{})
850
        if !ok {
851
            return ""
852
        }
853
        // Convert to string keys
854
        jsonMap = convertInterfaceMapToStringMap(jsonMapInterface)
855
    }
856

857
    // Extract schema reference
858
    schema, exists := jsonMap["schema"]
859
    if !exists {
860
        return ""
861
    }
862

863
    schemaMap, ok := schema.(map[string]interface{})
864
    if !ok {
865
        // Try with interface{} keys
866
        schemaMapInterface, ok := schema.(map[interface{}]interface{})
867
        if !ok {
868
            return ""
869
        }
870
        // Convert to string keys
871
        schemaMap = convertInterfaceMapToStringMap(schemaMapInterface)
872
    }
873

874
    // Extract $ref directly
875
    if ref, exists := schemaMap["$ref"]; exists {
876
        if refStr, ok := ref.(string); ok {
877
            if strings.HasPrefix(refStr, "#/components/schemas/") {
878
                schemaName := strings.TrimPrefix(refStr, "#/components/schemas/")
879
                return schemaName
880
            }
881
        }
882
    }
883

884
    return ""
885
}
886


			
quizapp internal middleware validation.go
0.0%
Statements
0/114
1
package middleware
2

3
import (
4
    "bytes"
5
    "encoding/json"
6
    "fmt"
7
    "io"
8
    "math"
9
    "net/http"
10
    "strings"
11

12
    "quizapp/internal/observability"
13

14
    "github.com/gin-gonic/gin"
15
)
16

17
// Global schema loader instance
18
var globalSchemaLoader *SchemaLoader
19

20
// initSchemaLoader initializes the global schema loader once
21
func initSchemaLoader() *SchemaLoader {
22
    if globalSchemaLoader == nil {
23
        globalSchemaLoader = AutoLoadSchemas()
24
    }
25
    return globalSchemaLoader
26
}
27

28
// ResponseValidationMiddleware creates middleware that automatically validates responses
29
func ResponseValidationMiddleware(logger *observability.Logger) gin.HandlerFunc {
30
    // Initialize schema loader once
31
    schemaLoader := initSchemaLoader()
32

33
    return func(c *gin.Context) {
34
        // Start tracing span for validation
35
        ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "response_validation")
36
        defer span.End()
37

38
        // Store the original response writer
39
        originalWriter := c.Writer
40

41
        // Create a custom response writer that captures the response
42
        responseWriter := &responseCaptureWriter{
43
            ResponseWriter: originalWriter,
44
            body:           &bytes.Buffer{},
45
            status:         0,
46
        }
47

48
        // Replace the response writer
49
        c.Writer = responseWriter
50

51
        // Continue to the next handler
52
        c.Next()
53

54
        // After the response is written, validate it
55
        statusCode := responseWriter.status
56
        if statusCode == 0 {
57
            statusCode = c.Writer.Status()
58
        }
59

60
        // Only validate 2xx responses
61
        if statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices {
62
            // Skip validation for streaming responses
63
            contentType := c.Writer.Header().Get("Content-Type")
64
            if contentType == "text/event-stream" {
65
                span.SetAttributes(
66
                    observability.AttributeTypeFilter("streaming_response"),
67
                )
68
                logger.Debug(ctx, "Skipping validation for streaming response", map[string]interface{}{
69
                    "method": c.Request.Method,
70
                    "path":   c.Request.URL.Path,
71
                })
72
                // Write the buffered response to the real writer
73
                c.Writer = originalWriter
74
                c.Writer.WriteHeader(statusCode)
75
                _, _ = c.Writer.Write(responseWriter.body.Bytes())
76
                return
77
            }
78

79
            // Try to parse the response as JSON
80
            var responseData interface{}
81
            err := json.Unmarshal(responseWriter.body.Bytes(), &responseData)
82
            if err == nil {
83
                // Determine schema name from the endpoint for the actual status code
84
                schemaName := schemaLoader.DetermineResponseSchemaFromPath(c.Request.URL.Path, c.Request.Method, fmt.Sprintf("%d", statusCode))
85
                if schemaName == "" {
86
                    // Fallback to generic success schema resolution if exact status not found
87
                    schemaName = schemaLoader.DetermineSchemaFromPath(c.Request.URL.Path, c.Request.Method)
88
                }
89

90
                // Add tracing attributes
91
                span.SetAttributes(
92
                    observability.AttributeSearch(c.Request.URL.Path),
93
                    observability.AttributeTypeFilter(c.Request.Method),
94
                )
95

96
                if schemaName != "" {
97
                    span.SetAttributes(observability.AttributeSearch(schemaName))
98

99
                    if err := schemaLoader.ValidateData(responseData, schemaName); err != nil {
100
                        // Log the validation error and add tracing attributes
101
                        span.SetAttributes(
102
                            observability.AttributeTypeFilter("validation_failed"),
103
                        )
104

105
                        // Log the validation error and fail the request
106
                        logger.Error(ctx, "Response validation failed", err, map[string]interface{}{
107
                            "method":        c.Request.Method,
108
                            "path":          c.Request.URL.Path,
109
                            "schema_name":   schemaName,
110
                            "error":         err.Error(),
111
                            "response_data": responseWriter.body.String()[:int(math.Min(200, float64(responseWriter.body.Len())))],
112
                        })
113

114
                        // Write a 400 error response instead of the original response
115
                        c.Writer = originalWriter
116
                        c.Writer.WriteHeader(http.StatusBadRequest)
117
                        _ = json.NewEncoder(c.Writer).Encode(gin.H{
118
                            "error":   "Response validation failed",
119
                            "message": "API response does not match the specification",
120
                            "method":  c.Request.Method,
121
                            "path":    c.Request.URL.Path,
122
                            "schema":  schemaName,
123
                            "details": err.Error(),
124
                        })
125
                        return
126
                    }
127
                    // Add success tracing attributes
128
                    span.SetAttributes(
129
                        observability.AttributeTypeFilter("validation_passed"),
130
                    )
131

132
                    // Write the buffered response to the real writer
133
                    c.Writer = originalWriter
134
                    c.Writer.WriteHeader(statusCode)
135
                    _, _ = c.Writer.Write(responseWriter.body.Bytes())
136
                    return
137
                }
138
                // No schema found for this endpoint
139
                span.SetAttributes(
140
                    observability.AttributeTypeFilter("no_schema_found"),
141
                )
142

143
                logger.Warn(ctx, "No schema found for endpoint", map[string]interface{}{
144
                    "method": c.Request.Method,
145
                    "path":   c.Request.URL.Path,
146
                })
147
                // Write the buffered response to the real writer
148
                c.Writer = originalWriter
149
                c.Writer.WriteHeader(statusCode)
150
                _, _ = c.Writer.Write(responseWriter.body.Bytes())
151
                return
152
            }
153
            // Failed to parse JSON response
154
            span.SetAttributes(
155
                observability.AttributeTypeFilter("json_parse_failed"),
156
            )
157

158
            logger.Error(ctx, "Failed to parse JSON response", err, map[string]interface{}{
159
                "method": c.Request.Method,
160
                "path":   c.Request.URL.Path,
161
            })
162
            // Write the buffered response to the real writer
163
            c.Writer = originalWriter
164
            c.Writer.WriteHeader(statusCode)
165
            _, _ = c.Writer.Write(responseWriter.body.Bytes())
166
            return
167
        }
168
        // Non-200 status code, skip validation
169
        span.SetAttributes(
170
            observability.AttributeTypeFilter("non_200_status"),
171
        )
172
        // Write the buffered response to the real writer
173
        c.Writer = originalWriter
174
        c.Writer.WriteHeader(statusCode)
175
        _, _ = c.Writer.Write(responseWriter.body.Bytes())
176
    }
177
}
178

179
// responseCaptureWriter captures the response body for validation
180
// Add a status field to track the status code
181
type responseCaptureWriter struct {
182
    gin.ResponseWriter
183
    body   *bytes.Buffer
184
    status int
185
}
186

187
func (w *responseCaptureWriter) WriteHeader(statusCode int) {
188
    w.status = statusCode
189
    w.ResponseWriter.WriteHeader(statusCode)
190
}
191

192
func (w *responseCaptureWriter) Write(b []byte) (int, error) {
193
    return w.body.Write(b)
194
}
195

196
func (w *responseCaptureWriter) Status() int {
197
    if w.status != 0 {
198
        return w.status
199
    }
200
    return w.ResponseWriter.Status()
201
}
202

203
// isStaticFile checks if a path is a static file that should be allowed to pass through
204
func isStaticFile(path string) bool {
205
    staticPaths := []string{
206
        "/swagger.yaml",
207
        "/swaggerz",
208
        "/configz",
209
        "/",
210
    }
211

212
    for _, staticPath := range staticPaths {
213
        if path == staticPath {
214
            return true
215
        }
216
    }
217

218
    // Also allow paths that start with /backend/ (static assets)
219
    if strings.HasPrefix(path, "/backend/") {
220
        return true
221
    }
222

223
    return false
224
}
225

226
// RequestValidationMiddleware creates middleware that prevents undocumented API calls
227
func RequestValidationMiddleware(logger *observability.Logger) gin.HandlerFunc {
228
    // Initialize schema loader once
229
    schemaLoader := initSchemaLoader()
230

231
    return func(c *gin.Context) {
232
        // Start tracing span for request validation
233
        ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "request_validation")
234
        defer span.End()
235

236
        // Check if the endpoint exists in the swagger spec
237
        path := c.Request.URL.Path
238
        method := c.Request.Method
239

240
        // Log all requests for debugging
241
        logger.Info(ctx, "Request validation middleware called", map[string]interface{}{
242
            "method": method,
243
            "path":   path,
244
        })
245

246
        // Add tracing attributes
247
        span.SetAttributes(
248
            observability.AttributeSearch(path),
249
            observability.AttributeTypeFilter(method),
250
        )
251

252
        // Allow static files to pass through
253
        if isStaticFile(path) {
254
            // Continue to the next handler
255
            c.Next()
256
            return
257
        }
258

259
        // Check if this endpoint is documented in swagger
260
        if !schemaLoader.IsEndpointDocumented(path, method) {
261
            // Log the undocumented API call
262
            logger.Warn(ctx, "Undocumented API call attempted", map[string]interface{}{
263
                "method":     method,
264
                "path":       path,
265
                "ip":         c.ClientIP(),
266
                "user_agent": c.Request.UserAgent(),
267
            })
268

269
            // Return 404 for undocumented endpoints
270
            c.JSON(http.StatusNotFound, gin.H{
271
                "error":   "Endpoint not found",
272
                "message": "The requested endpoint is not documented in the API specification",
273
            })
274
            c.Abort()
275
            return
276
        }
277

278
        // Endpoint is documented, continue
279
        span.SetAttributes(
280
            observability.AttributeTypeFilter("endpoint_documented"),
281
        )
282

283
        // Validate request body against schema for POST/PUT/PATCH requests
284
        if method == "POST" || method == "PUT" || method == "PATCH" {
285
            // Determine the request body schema name for this endpoint
286
            schemaName := schemaLoader.DetermineRequestSchemaFromPath(path, method)
287

288
            // Log the schema determination for debugging
289
            logger.Info(ctx, "Request validation schema determined", map[string]interface{}{
290
                "method":      method,
291
                "path":        path,
292
                "schema_name": schemaName,
293
            })
294

295
            // Log when no schema is found
296
            if schemaName == "" {
297
                logger.Warn(ctx, "No schema found for endpoint", map[string]interface{}{
298
                    "method": method,
299
                    "path":   path,
300
                })
301
            }
302

303
            // Restore the request body so handlers can read it
304
            body, err := c.GetRawData()
305
            if err == nil && len(body) > 0 {
306
                c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
307
            }
308

309
            if schemaName != "" {
310
                // Read the request body without consuming it
311
                body, err := c.GetRawData()
312
                if err == nil && len(body) > 0 {
313
                    // Restore the request body so handlers can read it
314
                    c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
315

316
                    // Log the raw request body for debugging
317
                    logger.Info(ctx, "Request body received", map[string]interface{}{
318
                        "method":      method,
319
                        "path":        path,
320
                        "schema_name": schemaName,
321
                        "body":        string(body),
322
                    })
323

324
                    // Parse the JSON
325
                    var requestData interface{}
326
                    if err := json.Unmarshal(body, &requestData); err == nil {
327
                        // Validate the request data against the schema
328
                        if err := schemaLoader.ValidateData(requestData, schemaName); err != nil {
329
                            // Log the validation error and the request data
330
                            logger.Error(ctx, "Request validation failed", err, map[string]interface{}{
331
                                "method":       method,
332
                                "path":         path,
333
                                "schema_name":  schemaName,
334
                                "error":        err.Error(),
335
                                "request_data": requestData,
336
                                "raw_body":     string(body),
337
                            })
338
                            // Add validation error details to tracing span
339
                            span.SetAttributes(
340
                                observability.AttributeTypeFilter("validation_failed"),
341
                                observability.AttributeSearch(path),
342
                                observability.AttributeTypeFilter(method),
343
                                observability.AttributeTypeFilter(schemaName),
344
                                observability.AttributeTypeFilter("validation_error:"+err.Error()),
345
                                observability.AttributeTypeFilter("request_data:"+fmt.Sprintf("%v", requestData)),
346
                                observability.AttributeTypeFilter("raw_body:"+string(body)),
347
                            )
348
                            // Print a concise summary to stdout for test debug
349
                            fmt.Printf("\n[VALIDATION ERROR] %v\n[REQUEST DATA] %v\n[RAW BODY] %s\n\n", err, requestData, string(body))
350
                            // Return 400 for invalid request data
351
                            c.JSON(http.StatusBadRequest, gin.H{
352
                                "error":   "Invalid request data",
353
                                "message": "Request data does not match the API specification",
354
                                "method":  method,
355
                                "path":    path,
356
                                "schema":  schemaName,
357
                                "details": err.Error(),
358
                            })
359
                            c.Abort()
360
                            return
361
                        }
362
                    }
363

364
                    // Restore the request body so handlers can read it
365
                    c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
366
                }
367
            }
368
        }
369

370
        // Continue to the next handler
371
        c.Next()
372
    }
373
}
374


			
quizapp internal models
42.9%
Statements
36/84
auth_api_key.go
0.0%
0/4
models.go
100.0%
36/36
story.go
0.0%
0/43
word_of_the_day.go
0.0%
0/1
quizapp internal models word_of_the_day.go
0.0%
Statements
0/4
1
package models
2

3
import (
4
    "database/sql"
5
    "time"
6
)
7

8
// AuthAPIKey represents an API key for programmatic authentication
9
// This is separate from user_api_keys which stores AI provider API keys
10
type AuthAPIKey struct {
11
    ID              int          `json:"id"`
12
    UserID          int          `json:"user_id"`
13
    KeyName         string       `json:"key_name"`
14
    KeyHash         string       `json:"-"` // Never expose the hash
15
    KeyPrefix       string       `json:"key_prefix"`
16
    PermissionLevel string       `json:"permission_level"` // "readonly" or "full"
17
    LastUsedAt      sql.NullTime `json:"last_used_at"`
18
    CreatedAt       time.Time    `json:"created_at"`
19
    UpdatedAt       time.Time    `json:"updated_at"`
20
}
21

22
// PermissionLevel constants
23
const (
24
    PermissionLevelReadonly = "readonly"
25
    PermissionLevelFull     = "full"
26
)
27

28
// IsValidPermissionLevel checks if the permission level is valid
29
func IsValidPermissionLevel(level string) bool {
30
    return level == PermissionLevelReadonly || level == PermissionLevelFull
31
}
32

33
// CanPerformMethod checks if the permission level allows the given HTTP method
34
func (k *AuthAPIKey) CanPerformMethod(method string) bool {
35
    if k.PermissionLevel == PermissionLevelFull {
36
        return true
37
    }
38
    // Readonly keys can only perform GET and HEAD requests
39
    return method == "GET" || method == "HEAD"
40
}
41


			
quizapp internal models word_of_the_day.go
100.0%
Statements
36/36
1
// Package models defines data structures used throughout the quiz application.
2
package models
3

4
import (
5
    "database/sql"
6
    "encoding/json"
7
    "time"
8

9
    "quizapp/internal/api"
10
)
11

12
// User represents a user in the system
13
type User struct {
14
    ID                    int            `json:"id" yaml:"id"`
15
    Username              string         `json:"username" yaml:"username"`
16
    Email                 sql.NullString `json:"email" yaml:"email"`
17
    Timezone              sql.NullString `json:"timezone" yaml:"timezone"`
18
    PasswordHash          sql.NullString `json:"-" yaml:"-"` // Omit from JSON responses
19
    LastActive            sql.NullTime   `json:"last_active" yaml:"last_active"`
20
    PreferredLanguage     sql.NullString `json:"preferred_language" yaml:"preferred_language"`
21
    CurrentLevel          sql.NullString `json:"current_level" yaml:"current_level"`
22
    AIProvider            sql.NullString `json:"ai_provider" yaml:"ai_provider"`
23
    AIModel               sql.NullString `json:"ai_model" yaml:"ai_model"`
24
    AIEnabled             sql.NullBool   `json:"ai_enabled" yaml:"ai_enabled"`
25
    AIAPIKey              sql.NullString `json:"-" yaml:"ai_api_key"` // Omit from JSON responses
26
    WordOfDayEmailEnabled sql.NullBool   `json:"word_of_day_email_enabled" yaml:"word_of_day_email_enabled"`
27
    CreatedAt             time.Time      `json:"created_at" yaml:"created_at"`
28
    UpdatedAt             time.Time      `json:"updated_at" yaml:"updated_at"`
29
    Roles                 []Role         `json:"roles,omitempty" yaml:"roles,omitempty"`
30
}
31

32
// Role represents a role in the system
33
type Role struct {
34
    ID          int       `json:"id" yaml:"id"`
35
    Name        string    `json:"name" yaml:"name"`
36
    Description string    `json:"description" yaml:"description"`
37
    CreatedAt   time.Time `json:"created_at" yaml:"created_at"`
38
    UpdatedAt   time.Time `json:"updated_at" yaml:"updated_at"`
39
}
40

41
// UserRole represents the mapping between users and roles
42
type UserRole struct {
43
    ID        int       `json:"id" yaml:"id"`
44
    UserID    int       `json:"user_id" yaml:"user_id"`
45
    RoleID    int       `json:"role_id" yaml:"role_id"`
46
    CreatedAt time.Time `json:"created_at" yaml:"created_at"`
47
}
48

49
// Snippet represents a vocabulary snippet saved by a user
50
type Snippet struct {
51
    ID              int64     `json:"id" yaml:"id"`
52
    UserID          int64     `json:"user_id" yaml:"user_id"`
53
    OriginalText    string    `json:"original_text" yaml:"original_text"`
54
    TranslatedText  string    `json:"translated_text" yaml:"translated_text"`
55
    SourceLanguage  string    `json:"source_language" yaml:"source_language"`
56
    TargetLanguage  string    `json:"target_language" yaml:"target_language"`
57
    QuestionID      *int64    `json:"question_id" yaml:"question_id"`
58
    SectionID       *int64    `json:"section_id" yaml:"section_id"`
59
    StoryID         *int64    `json:"story_id" yaml:"story_id"`
60
    Context         *string   `json:"context" yaml:"context"`
61
    DifficultyLevel *string   `json:"difficulty_level" yaml:"difficulty_level"`
62
    CreatedAt       time.Time `json:"created_at" yaml:"created_at"`
63
    UpdatedAt       time.Time `json:"updated_at" yaml:"updated_at"`
64
}
65

66
// TranslationCache represents a cached translation result
67
type TranslationCache struct {
68
    ID             int       `json:"id" yaml:"id"`
69
    TextHash       string    `json:"text_hash" yaml:"text_hash"`
70
    OriginalText   string    `json:"original_text" yaml:"original_text"`
71
    SourceLanguage string    `json:"source_language" yaml:"source_language"`
72
    TargetLanguage string    `json:"target_language" yaml:"target_language"`
73
    TranslatedText string    `json:"translated_text" yaml:"translated_text"`
74
    CreatedAt      time.Time `json:"created_at" yaml:"created_at"`
75
    ExpiresAt      time.Time `json:"expires_at" yaml:"expires_at"`
76
}
77

78
// MarshalJSON customizes JSON marshaling for User to handle sql.NullString and sql.NullTime properly
79
14x
func (u User) MarshalJSON() (result0 []byte, err error) { // Create a struct with the desired JSON structure
80
14x
    return json.Marshal(&struct {
81
14x
        ID                int        `json:"id"`
82
14x
        Username          string     `json:"username"`
83
14x
        Email             *string    `json:"email"`
84
14x
        Timezone          *string    `json:"timezone"`
85
14x
        LastActive        *time.Time `json:"last_active"`
86
14x
        PreferredLanguage *string    `json:"preferred_language"`
87
14x
        CurrentLevel      *string    `json:"current_level"`
88
14x
        AIProvider        *string    `json:"ai_provider"`
89
14x
        AIModel           *string    `json:"ai_model"`
90
14x
        AIEnabled         *bool      `json:"ai_enabled"`
91
14x
        CreatedAt         time.Time  `json:"created_at"`
92
14x
        UpdatedAt         time.Time  `json:"updated_at"`
93
14x
        Roles             []Role     `json:"roles,omitempty"`
94
14x
    }{
95
14x
        ID:                u.ID,
96
14x
        Username:          u.Username,
97
14x
        Email:             nullStringToPointer(u.Email),
98
14x
        Timezone:          nullStringToPointer(u.Timezone),
99
14x
        LastActive:        nullTimeToPointer(u.LastActive),
100
14x
        PreferredLanguage: nullStringToPointer(u.PreferredLanguage),
101
14x
        CurrentLevel:      nullStringToPointer(u.CurrentLevel),
102
14x
        AIProvider:        nullStringToPointer(u.AIProvider),
103
14x
        AIModel:           nullStringToPointer(u.AIModel),
104
14x
        AIEnabled:         nullBoolToPointer(u.AIEnabled),
105
14x
        CreatedAt:         u.CreatedAt,
106
14x
        UpdatedAt:         u.UpdatedAt,
107
14x
        Roles:             u.Roles,
108
14x
    })
109
14x
}
110

111
// Helper functions for converting sql.Null types to pointers
112
98x
func nullStringToPointer(ns sql.NullString) *string {
113
98x
    if ns.Valid {
114
33x
        return &ns.String
115
33x
    }
116
65x
    return nil
117
}
118

119
36x
func nullTimeToPointer(nt sql.NullTime) *time.Time {
120
36x
    if nt.Valid {
121
16x
        return &nt.Time
122
16x
    }
123
20x
    return nil
124
}
125

126
21x
func nullBoolToPointer(nb sql.NullBool) *bool {
127
21x
    if nb.Valid {
128
9x
        return &nb.Bool
129
9x
    }
130
12x
    return nil
131
}
132

133
4x
func nullInt32ToPointer(ni sql.NullInt32) *int32 {
134
4x
    if ni.Valid {
135
2x
        return &ni.Int32
136
2x
    }
137
2x
    return nil
138
}
139

140
// UserAPIKey represents an API key for a specific provider for a user
141
type UserAPIKey struct {
142
    ID        int       `json:"id"`
143
    UserID    int       `json:"user_id"`
144
    Provider  string    `json:"provider"`
145
    APIKey    string    `json:"-"` // Omit from JSON responses for security
146
    CreatedAt time.Time `json:"created_at"`
147
    UpdatedAt time.Time `json:"updated_at"`
148
}
149

150
// Question represents a quiz question
151
type Question struct {
152
    ID              int                    `json:"id" yaml:"id"`
153
    Type            QuestionType           `json:"type" yaml:"type"`
154
    Language        string                 `json:"language" yaml:"language"`
155
    Level           string                 `json:"level" yaml:"level"`
156
    DifficultyScore float64                `json:"difficulty_score" yaml:"difficulty_score"`
157
    Content         map[string]interface{} `json:"content" yaml:"content"`
158
    CorrectAnswer   int                    `json:"correct_answer" yaml:"correct_answer"`
159
    Explanation     string                 `json:"explanation,omitempty" yaml:"explanation"`
160
    CreatedAt       time.Time              `json:"created_at" yaml:"created_at"`
161
    Status          QuestionStatus         `json:"status" yaml:"status"`
162
    // Test data field for specifying which users should have this question
163
    Users []string `json:"users,omitempty" yaml:"users,omitempty"`
164
    // Variety elements for question generation diversity
165
    TopicCategory      string `json:"topic_category,omitempty" yaml:"topic_category"`
166
    GrammarFocus       string `json:"grammar_focus,omitempty" yaml:"grammar_focus"`
167
    VocabularyDomain   string `json:"vocabulary_domain,omitempty" yaml:"vocabulary_domain"`
168
    Scenario           string `json:"scenario,omitempty" yaml:"scenario"`
169
    StyleModifier      string `json:"style_modifier,omitempty" yaml:"style_modifier"`
170
    DifficultyModifier string `json:"difficulty_modifier,omitempty" yaml:"difficulty_modifier"`
171
    TimeContext        string `json:"time_context,omitempty" yaml:"time_context"`
172
}
173

174
// UserQuestion represents the mapping between users and questions
175
type UserQuestion struct {
176
    ID         int       `json:"id"`
177
    UserID     int       `json:"user_id"`
178
    QuestionID int       `json:"question_id"`
179
    CreatedAt  time.Time `json:"created_at"`
180
}
181

182
// QuestionReport represents a report of a question by a user
183
type QuestionReport struct {
184
    ID               int       `json:"id"`
185
    QuestionID       int       `json:"question_id"`
186
    ReportedByUserID int       `json:"reported_by_user_id"`
187
    ReportReason     string    `json:"report_reason"`
188
    CreatedAt        time.Time `json:"created_at"`
189
}
190

191
// QuestionType represents the type of question
192
type QuestionType string
193

194
// QuestionStatus represents the status of a question
195
type QuestionStatus string
196

197
const (
198
    // QuestionStatusActive is for questions that are in active use
199
    QuestionStatusActive QuestionStatus = "active"
200
    // QuestionStatusReported is for questions that have been reported as incorrect
201
    QuestionStatusReported QuestionStatus = "reported"
202
)
203

204
// Question types supported by the system
205
const (
206
    // Vocabulary represents vocabulary in context questions
207
    Vocabulary QuestionType = "vocabulary"
208
    // FillInBlank represents fill-in-the-blank questions
209
    FillInBlank QuestionType = "fill_blank"
210
    // QuestionAnswer represents simple Q&A questions
211
    QuestionAnswer QuestionType = "qa"
212
    // ReadingComprehension represents reading comprehension questions
213
    ReadingComprehension QuestionType = "reading_comprehension"
214
)
215

216
// UserResponse represents a user's answer to a question
217
type UserResponse struct {
218
    ID              int           `json:"id" yaml:"id"`
219
    UserID          int           `json:"user_id" yaml:"user_id"`
220
    QuestionID      int           `json:"question_id" yaml:"question_id"`
221
    UserAnswerIndex int           `json:"user_answer_index" yaml:"user_answer_index"`
222
    IsCorrect       bool          `json:"is_correct" yaml:"is_correct"`
223
    ResponseTimeMs  int           `json:"response_time_ms" yaml:"response_time_ms"`
224
    ConfidenceLevel sql.NullInt32 `json:"confidence_level" yaml:"confidence_level"`
225
    CreatedAt       time.Time     `json:"created_at" yaml:"created_at"`
226
}
227

228
// MarshalJSON customizes JSON marshaling for UserResponse to handle sql.NullInt32 properly
229
2x
func (ur UserResponse) MarshalJSON() (result0 []byte, err error) {
230
2x
    return json.Marshal(&struct {
231
2x
        ID              int       `json:"id"`
232
2x
        UserID          int       `json:"user_id"`
233
2x
        QuestionID      int       `json:"question_id"`
234
2x
        UserAnswerIndex int       `json:"user_answer_index"`
235
2x
        IsCorrect       bool      `json:"is_correct"`
236
2x
        ResponseTimeMs  int       `json:"response_time_ms"`
237
2x
        ConfidenceLevel *int32    `json:"confidence_level"`
238
2x
        CreatedAt       time.Time `json:"created_at"`
239
2x
    }{
240
2x
        ID:              ur.ID,
241
2x
        UserID:          ur.UserID,
242
2x
        QuestionID:      ur.QuestionID,
243
2x
        UserAnswerIndex: ur.UserAnswerIndex,
244
2x
        IsCorrect:       ur.IsCorrect,
245
2x
        ResponseTimeMs:  ur.ResponseTimeMs,
246
2x
        ConfidenceLevel: nullInt32ToPointer(ur.ConfidenceLevel),
247
2x
        CreatedAt:       ur.CreatedAt,
248
2x
    })
249
2x
}
250

251
// PerformanceMetrics tracks user performance across different categories
252
type PerformanceMetrics struct {
253
    ID                    int       `json:"id"`
254
    UserID                int       `json:"user_id"`
255
    Topic                 string    `json:"topic"`
256
    Language              string    `json:"language"`
257
    Level                 string    `json:"level"`
258
    TotalAttempts         int       `json:"total_attempts"`
259
    CorrectAttempts       int       `json:"correct_attempts"`
260
    AverageResponseTimeMs float64   `json:"average_response_time_ms"`
261
    DifficultyAdjustment  float64   `json:"difficulty_adjustment"`
262
    LastUpdated           time.Time `json:"last_updated"`
263
}
264

265
// AccuracyRate calculates the accuracy percentage
266
5x
func (pm *PerformanceMetrics) AccuracyRate() float64 {
267
5x
    if pm.TotalAttempts == 0 {
268
1x
        return 0.0
269
1x
    }
270
4x
    return float64(pm.CorrectAttempts) / float64(pm.TotalAttempts) * 100
271
}
272

273
// QuestionRequest represents a request for a new question
274
type QuestionRequest struct {
275
    UserID       int          `json:"user_id"`
276
    Language     string       `json:"language"`
277
    Level        string       `json:"level"`
278
    QuestionType QuestionType `json:"question_type,omitempty"`
279
}
280

281
// AnswerRequest represents a user's answer submission
282
type AnswerRequest struct {
283
    QuestionID     int    `json:"question_id"`
284
    UserAnswer     string `json:"user_answer"`
285
    ResponseTimeMs int    `json:"response_time_ms"`
286
}
287

288
// AnswerResponse represents the response to an answer submission
289
type AnswerResponse struct {
290
    IsCorrect      bool   `json:"is_correct"`
291
    CorrectAnswer  string `json:"correct_answer"`
292
    UserAnswer     string `json:"user_answer"`
293
    Explanation    string `json:"explanation"`
294
    NextDifficulty string `json:"next_difficulty,omitempty"`
295
}
296

297
// GetCorrectAnswerText returns the text of the correct answer from the question content
298
22x
func (q *Question) GetCorrectAnswerText() string {
299
22x
    if optionsRaw, ok := q.Content["options"]; ok {
300
17x
        if options, ok := optionsRaw.([]interface{}); ok {
301
15x
            if q.CorrectAnswer >= 0 && q.CorrectAnswer < len(options) {
302
12x
                if optStr, ok := options[q.CorrectAnswer].(string); ok {
303
12x
                    return optStr
304
12x
                }
305
            }
306
        }
307
    }
308
10x
    return ""
309
}
310

311
// UserSettings represents user preference settings
312
type UserSettings struct {
313
    Language   string `json:"language" yaml:"language"`
314
    Level      string `json:"level" yaml:"level"`
315
    AIProvider string `json:"ai_provider" yaml:"ai_provider"`
316
    AIModel    string `json:"ai_model" yaml:"ai_model"`
317
    AIEnabled  bool   `json:"ai_enabled" yaml:"ai_enabled"`
318
    AIAPIKey   string `json:"api_key" yaml:"ai_api_key"`
319
}
320

321
// UserLearningPreferences represents user learning preferences and settings
322
type UserLearningPreferences struct {
323
    ID                        int      `json:"id" db:"id"`
324
    UserID                    int      `json:"user_id" db:"user_id"`
325
    PreferredLanguage         string   `json:"preferred_language" db:"preferred_language"`
326
    CurrentLevel              string   `json:"current_level" db:"current_level"`
327
    AIProvider                string   `json:"ai_provider" db:"ai_provider"`
328
    AIModel                   string   `json:"ai_model" db:"ai_model"`
329
    AIEnabled                 bool     `json:"ai_enabled" db:"ai_enabled"`
330
    AIAPIKey                  string   `json:"-" db:"ai_api_key"` // Omit from JSON for security
331
    DailyGoal                 int      `json:"daily_goal" db:"daily_goal"`
332
    WeeklyGoal                int      `json:"weekly_goal" db:"weekly_goal"`
333
    PreferredQuestionType     string   `json:"preferred_question_type" db:"preferred_question_type"`
334
    PreferredQuestionTypes    []string `json:"preferred_question_types" db:"preferred_question_types"`
335
    PreferredDifficultyLevel  string   `json:"preferred_difficulty_level" db:"preferred_difficulty_level"`
336
    PreferredTopics           []string `json:"preferred_topics" db:"preferred_topics"`
337
    PreferredQuestionCount    int      `json:"preferred_question_count" db:"preferred_question_count"`
338
    SpacedRepetitionEnabled   bool     `json:"spaced_repetition_enabled" db:"spaced_repetition_enabled"`
339
    AdaptiveDifficultyEnabled bool     `json:"adaptive_difficulty_enabled" db:"adaptive_difficulty_enabled"`
340
    FocusOnWeakAreas          bool     `json:"focus_on_weak_areas" db:"focus_on_weak_areas"`
341
    IncludeReviewQuestions    bool     `json:"include_review_questions" db:"include_review_questions"`
342
    FreshQuestionRatio        float64  `json:"fresh_question_ratio" db:"fresh_question_ratio"`
343
    KnownQuestionPenalty      float64  `json:"known_question_penalty" db:"known_question_penalty"`
344
    ReviewIntervalDays        int      `json:"review_interval_days" db:"review_interval_days"`
345
    WeakAreaBoost             float64  `json:"weak_area_boost" db:"weak_area_boost"`
346
    StudyTime                 string   `json:"study_time" db:"study_time"`
347
    DailyReminderEnabled      bool     `json:"daily_reminder_enabled" db:"daily_reminder_enabled"`
348
    // Preferred TTS voice (e.g., it-IT-IsabellaNeural)
349
    TTSVoice              string     `json:"tts_voice" db:"tts_voice"`
350
    LastDailyReminderSent *time.Time `json:"last_daily_reminder_sent" db:"last_daily_reminder_sent"`
351
    CreatedAt             time.Time  `json:"created_at" db:"created_at"`
352
    UpdatedAt             time.Time  `json:"updated_at" db:"updated_at"`
353
}
354

355
// UserProgress represents a user's overall progress
356
type UserProgress struct {
357
    CurrentLevel       string                         `json:"current_level"`
358
    TotalQuestions     int                            `json:"total_questions"`
359
    CorrectAnswers     int                            `json:"correct_answers"`
360
    AccuracyRate       float64                        `json:"accuracy_rate"`
361
    PerformanceByTopic map[string]*PerformanceMetrics `json:"performance_by_topic"`
362
    WeakAreas          []string                       `json:"weak_areas"`
363
    RecentActivity     []UserResponse                 `json:"recent_activity"`
364
    SuggestedLevel     string                         `json:"suggested_level,omitempty"`
365
}
366

367
// AIQuestionGenRequest represents a request to the AI service for question generation
368
type AIQuestionGenRequest struct {
369
    Language              string       `json:"language"`
370
    Level                 string       `json:"level"`
371
    QuestionType          QuestionType `json:"question_type"`
372
    Count                 int          `json:"count"`
373
    RecentQuestionHistory []string     `json:"-"` // Don't include in JSON, internal use
374
}
375

376
// AIChatRequest represents a request to the AI service for a new chat feature
377
type AIChatRequest struct {
378
    Language              string
379
    Level                 string
380
    QuestionType          QuestionType // Question type for context
381
    Question              string
382
    Options               []string
383
    Passage               string // For reading comprehension
384
    UserAnswer            string // Optional
385
    CorrectAnswer         string // Optional
386
    IsCorrect             *bool  // Optional
387
    UserMessage           string
388
    ConversationHistory   []ChatMessage `json:"conversation_history,omitempty"`
389
    RecentQuestionHistory []string      `json:"-"` // Don't include in JSON, internal use
390
}
391

392
// ChatMessage represents a single message in the chat conversation
393
type ChatMessage struct {
394
    Role    api.ChatMessageRole `json:"role"`    // "user" or "assistant"
395
    Content string              `json:"content"` // The message content
396
}
397

398
// AIExplanationRequest represents a request for an explanation of a wrong answer
399
type AIExplanationRequest struct {
400
    Question      string `json:"question"`
401
    UserAnswer    string `json:"user_answer"`
402
    CorrectAnswer string `json:"correct_answer"`
403
    Language      string `json:"language"`
404
    Level         string `json:"level"`
405
}
406

407
// MarshalContentToJSON serializes the question content to JSON string
408
12x
func (q *Question) MarshalContentToJSON() (result0 string, err error) {
409
12x
    // Clean up fields that should be at the top level, not in content
410
12x
    // Remove fields that are not allowed in QuestionContent according to OpenAPI schema
411
12x
    if q.Content != nil {
412
9x
        // Always remove correct_answer from content as it should be at top level
413
9x
        delete(q.Content, "correct_answer")
414
9x
        // Always remove explanation from content as it should be at top level
415
9x
        delete(q.Content, "explanation")
416
9x
    }
417

418
12x
    data, err := json.Marshal(q.Content)
419
12x
    return string(data), err
420
}
421

422
// UnmarshalContentFromJSON deserializes JSON string into question content
423
14x
func (q *Question) UnmarshalContentFromJSON(data string) error {
424
14x
    err := json.Unmarshal([]byte(data), &q.Content)
425
14x
    if err != nil {
426
1x
        return err
427
1x
    }
428

429
    // Clean up fields that should be at the top level, not in content
430
    // Remove fields that are not allowed in QuestionContent according to OpenAPI schema
431
12x
    if q.Content != nil {
432
9x
        // Always remove correct_answer from content as it should be at top level
433
9x
        delete(q.Content, "correct_answer")
434
9x
        // Always remove explanation from content as it should be at top level
435
9x
        delete(q.Content, "explanation")
436
9x
    }
437

438
12x
    return nil
439
}
440

441
// WorkerSettings represents worker configuration settings stored in database
442
type WorkerSettings struct {
443
    ID           int       `json:"id" db:"id"`
444
    SettingKey   string    `json:"setting_key" db:"setting_key"`
445
    SettingValue string    `json:"setting_value" db:"setting_value"`
446
    CreatedAt    time.Time `json:"created_at" db:"created_at"`
447
    UpdatedAt    time.Time `json:"updated_at" db:"updated_at"`
448
}
449

450
// WorkerStatus represents worker health and activity status
451
type WorkerStatus struct {
452
    ID                      int            `json:"id" db:"id"`
453
    WorkerInstance          string         `json:"worker_instance" db:"worker_instance"`
454
    IsRunning               bool           `json:"is_running" db:"is_running"`
455
    IsPaused                bool           `json:"is_paused" db:"is_paused"`
456
    CurrentActivity         sql.NullString `json:"current_activity" db:"current_activity"`
457
    LastHeartbeat           sql.NullTime   `json:"last_heartbeat" db:"last_heartbeat"`
458
    LastRunStart            sql.NullTime   `json:"last_run_start" db:"last_run_start"`
459
    LastRunEnd              sql.NullTime   `json:"last_run_end" db:"last_run_end"`
460
    LastRunFinish           sql.NullTime   `json:"last_run_finish" db:"last_run_finish"`
461
    LastRunError            sql.NullString `json:"last_run_error" db:"last_run_error"`
462
    TotalQuestionsProcessed int            `json:"total_questions_processed" db:"total_questions_processed"`
463
    TotalQuestionsGenerated int            `json:"total_questions_generated" db:"total_questions_generated"`
464
    TotalRuns               int            `json:"total_runs" db:"total_runs"`
465
    CreatedAt               time.Time      `json:"created_at" db:"created_at"`
466
    UpdatedAt               time.Time      `json:"updated_at" db:"updated_at"`
467
}
468

469
// MarshalJSON customizes JSON marshaling for WorkerStatus to handle sql.NullString and sql.NullTime properly
470
2x
func (ws WorkerStatus) MarshalJSON() (result0 []byte, err error) {
471
2x
    return json.Marshal(&struct {
472
2x
        ID                      int        `json:"id"`
473
2x
        WorkerInstance          string     `json:"worker_instance"`
474
2x
        IsRunning               bool       `json:"is_running"`
475
2x
        IsPaused                bool       `json:"is_paused"`
476
2x
        CurrentActivity         *string    `json:"current_activity"`
477
2x
        LastHeartbeat           *time.Time `json:"last_heartbeat"`
478
2x
        LastRunStart            *time.Time `json:"last_run_start"`
479
2x
        LastRunEnd              *time.Time `json:"last_run_end"`
480
2x
        LastRunFinish           *time.Time `json:"last_run_finish"`
481
2x
        LastRunError            *string    `json:"last_run_error"`
482
2x
        TotalQuestionsProcessed int        `json:"total_questions_processed"`
483
2x
        TotalQuestionsGenerated int        `json:"total_questions_generated"`
484
2x
        TotalRuns               int        `json:"total_runs"`
485
2x
        CreatedAt               time.Time  `json:"created_at"`
486
2x
        UpdatedAt               time.Time  `json:"updated_at"`
487
2x
    }{
488
2x
        ID:                      ws.ID,
489
2x
        WorkerInstance:          ws.WorkerInstance,
490
2x
        IsRunning:               ws.IsRunning,
491
2x
        IsPaused:                ws.IsPaused,
492
2x
        CurrentActivity:         nullStringToPointer(ws.CurrentActivity),
493
2x
        LastHeartbeat:           nullTimeToPointer(ws.LastHeartbeat),
494
2x
        LastRunStart:            nullTimeToPointer(ws.LastRunStart),
495
2x
        LastRunEnd:              nullTimeToPointer(ws.LastRunEnd),
496
2x
        LastRunFinish:           nullTimeToPointer(ws.LastRunFinish),
497
2x
        LastRunError:            nullStringToPointer(ws.LastRunError),
498
2x
        TotalQuestionsProcessed: ws.TotalQuestionsProcessed,
499
2x
        TotalQuestionsGenerated: ws.TotalQuestionsGenerated,
500
2x
        TotalRuns:               ws.TotalRuns,
501
2x
        CreatedAt:               ws.CreatedAt,
502
2x
        UpdatedAt:               ws.UpdatedAt,
503
2x
    })
504
2x
}
505


			
quizapp internal models word_of_the_day.go
0.0%
Statements
0/43
1
package models
2

3
import (
4
    "errors"
5
    "strings"
6
    "time"
7
)
8

9
// StoryStatus represents the status of a story
10
type StoryStatus string
11

12
// Story status constants
13
const (
14
    StoryStatusActive    StoryStatus = "active"    // StoryStatusActive represents an active story
15
    StoryStatusArchived  StoryStatus = "archived"  // StoryStatusArchived represents an archived story
16
    StoryStatusCompleted StoryStatus = "completed" // StoryStatusCompleted represents a completed story
17
)
18

19
// SectionLength represents the preferred length of story sections
20
type SectionLength string
21

22
// Section length constants
23
const (
24
    SectionLengthShort  SectionLength = "short"  // SectionLengthShort represents a short section length
25
    SectionLengthMedium SectionLength = "medium" // SectionLengthMedium represents a medium section length
26
    SectionLengthLong   SectionLength = "long"   // SectionLengthLong represents a long section length
27
)
28

29
// GeneratorType represents who generated a story section
30
type GeneratorType string
31

32
// Generator type constants
33
const (
34
    GeneratorTypeWorker GeneratorType = "worker" // GeneratorTypeWorker represents worker-generated sections
35
    GeneratorTypeUser   GeneratorType = "user"   // GeneratorTypeUser represents user-generated sections
36
)
37

38
// Story represents a user-created story with metadata
39
type Story struct {
40
    ID                     uint           `json:"id"`
41
    UserID                 uint           `json:"user_id"`
42
    Title                  string         `json:"title"`
43
    Language               string         `json:"language"`
44
    Subject                *string        `json:"subject"`
45
    AuthorStyle            *string        `json:"author_style"`
46
    TimePeriod             *string        `json:"time_period"`
47
    Genre                  *string        `json:"genre"`
48
    Tone                   *string        `json:"tone"`
49
    CharacterNames         *string        `json:"character_names"`
50
    CustomInstructions     *string        `json:"custom_instructions"`
51
    SectionLengthOverride  *SectionLength `json:"section_length_override,omitempty"`
52
    Status                 StoryStatus    `json:"status"`
53
    AutoGenerationPaused   bool           `json:"auto_generation_paused"`
54
    LastSectionGeneratedAt *time.Time     `json:"last_section_generated_at"`
55
    ExtraGenerationsToday  int            `json:"extra_generations_today"`
56
    CreatedAt              time.Time      `json:"created_at"`
57
    UpdatedAt              time.Time      `json:"updated_at"`
58

59
    // Relationships
60
    User     User           `json:"user,omitempty"`
61
    Sections []StorySection `json:"sections,omitempty"`
62
}
63

64
// GetSectionLengthOverride returns the section length override as a string, handling nil pointers
65
func (s *Story) GetSectionLengthOverride() string {
66
    if s.SectionLengthOverride == nil {
67
        return ""
68
    }
69
    return string(*s.SectionLengthOverride)
70
}
71

72
// StorySection represents an individual section of a story
73
type StorySection struct {
74
    ID             uint          `json:"id"`
75
    StoryID        uint          `json:"story_id"`
76
    SectionNumber  int           `json:"section_number"`
77
    Content        string        `json:"content"`
78
    LanguageLevel  string        `json:"language_level"`
79
    WordCount      int           `json:"word_count"`
80
    GeneratedBy    GeneratorType `json:"generated_by"`
81
    GeneratedAt    time.Time     `json:"generated_at"`
82
    GenerationDate time.Time     `json:"generation_date"`
83

84
    // Relationships
85
    Story     Story                  `json:"story,omitempty"`
86
    Questions []StorySectionQuestion `json:"questions,omitempty"`
87
}
88

89
// StorySectionQuestion represents a comprehension question for a story section
90
type StorySectionQuestion struct {
91
    ID                 uint      `json:"id"`
92
    SectionID          uint      `json:"section_id"`
93
    QuestionText       string    `json:"question_text"`
94
    Options            []string  `json:"options"`
95
    CorrectAnswerIndex int       `json:"correct_answer_index"`
96
    Explanation        *string   `json:"explanation"`
97
    CreatedAt          time.Time `json:"created_at"`
98

99
    // Relationships
100
    Section StorySection `json:"section,omitempty"`
101
}
102

103
// StoryWithSections represents a story with all its sections loaded
104
type StoryWithSections struct {
105
    Story
106
    Sections []StorySection `json:"sections"`
107
}
108

109
// StorySectionWithQuestions represents a section with all its questions loaded
110
type StorySectionWithQuestions struct {
111
    StorySection
112
    Questions []StorySectionQuestion `json:"questions"`
113
}
114

115
// CreateStoryRequest represents the request to create a new story
116
type CreateStoryRequest struct {
117
    Title                 string         `json:"title" validate:"required,min=1,max=200"`
118
    Subject               *string        `json:"subject" validate:"omitempty,max=500"`
119
    AuthorStyle           *string        `json:"author_style" validate:"omitempty,max=200"`
120
    TimePeriod            *string        `json:"time_period" validate:"omitempty,max=200"`
121
    Genre                 *string        `json:"genre" validate:"omitempty,max=100"`
122
    Tone                  *string        `json:"tone" validate:"omitempty,max=100"`
123
    CharacterNames        *string        `json:"character_names" validate:"omitempty,max=1000"`
124
    CustomInstructions    *string        `json:"custom_instructions" validate:"omitempty,max=2000"`
125
    SectionLengthOverride *SectionLength `json:"section_length_override" validate:"omitempty,oneof=short medium long"`
126
}
127

128
// StoryGenerationRequest represents the request for AI story generation
129
type StoryGenerationRequest struct {
130
    UserID             uint          `json:"-"`
131
    StoryID            uint          `json:"-"`
132
    Language           string        `json:"language"`
133
    Level              string        `json:"level"`
134
    Title              string        `json:"title"`
135
    Subject            *string       `json:"subject,omitempty"`
136
    AuthorStyle        *string       `json:"author_style,omitempty"`
137
    TimePeriod         *string       `json:"time_period,omitempty"`
138
    Genre              *string       `json:"genre,omitempty"`
139
    Tone               *string       `json:"tone,omitempty"`
140
    CharacterNames     *string       `json:"character_names,omitempty"`
141
    CustomInstructions *string       `json:"custom_instructions,omitempty"`
142
    SectionLength      SectionLength `json:"section_length"`
143
    PreviousSections   string        `json:"previous_sections"`
144
    IsFirstSection     bool          `json:"is_first_section"`
145
    TargetWords        int           `json:"target_words"`
146
    TargetSentences    int           `json:"target_sentences"`
147
}
148

149
// StoryQuestionsRequest represents the request for AI question generation
150
type StoryQuestionsRequest struct {
151
    UserID        uint   `json:"-"`
152
    SectionID     uint   `json:"-"`
153
    Language      string `json:"language"`
154
    Level         string `json:"level"`
155
    SectionText   string `json:"section_text"`
156
    QuestionCount int    `json:"question_count"`
157
}
158

159
// StorySectionQuestionData represents the structure returned by AI for questions
160
type StorySectionQuestionData struct {
161
    QuestionText       string   `json:"question_text"`
162
    Options            []string `json:"options"`
163
    CorrectAnswerIndex int      `json:"correct_answer_index"`
164
    Explanation        *string  `json:"explanation"`
165
}
166

167
// Validate validates the CreateStoryRequest
168
func (r *CreateStoryRequest) Validate() error {
169
    if r.Title == "" {
170
        return errors.New("title is required")
171
    }
172
    if len(r.Title) > 200 {
173
        return errors.New("title must be 200 characters or less")
174
    }
175
    if r.Subject != nil && len(*r.Subject) > 500 {
176
        return errors.New("subject must be 500 characters or less")
177
    }
178
    if r.AuthorStyle != nil && len(*r.AuthorStyle) > 200 {
179
        return errors.New("author style must be 200 characters or less")
180
    }
181
    if r.TimePeriod != nil && len(*r.TimePeriod) > 200 {
182
        return errors.New("time period must be 200 characters or less")
183
    }
184
    if r.Genre != nil && len(*r.Genre) > 100 {
185
        return errors.New("genre must be 100 characters or less")
186
    }
187
    if r.Tone != nil && len(*r.Tone) > 100 {
188
        return errors.New("tone must be 100 characters or less")
189
    }
190
    if r.CharacterNames != nil && len(*r.CharacterNames) > 1000 {
191
        return errors.New("character names must be 1000 characters or less")
192
    }
193
    if r.CustomInstructions != nil && len(*r.CustomInstructions) > 2000 {
194
        return errors.New("custom instructions must be 2000 characters or less")
195
    }
196
    if r.SectionLengthOverride != nil {
197
        switch *r.SectionLengthOverride {
198
        case SectionLengthShort, SectionLengthMedium, SectionLengthLong:
199
            // Valid
200
        default:
201
            return errors.New("section length override must be one of: short, medium, long")
202
        }
203
    }
204
    return nil
205
}
206

207
// SanitizeInput sanitizes user input for safe use in AI prompts
208
func SanitizeInput(input string) string {
209
    // Basic sanitization - remove control characters and trim whitespace
210
    // In a production system, you might want more sophisticated sanitization
211
    result := strings.TrimSpace(input)
212

213
    // Remove null bytes and control characters
214
    for i := 0; i < len(result); i++ {
215
        if result[i] < 32 && result[i] != 9 && result[i] != 10 && result[i] != 13 {
216
            result = result[:i] + result[i+1:]
217
            i--
218
        }
219
    }
220

221
    return result
222
}
223

224
// UserAIConfig holds per-user AI configuration
225
type UserAIConfig struct {
226
    Provider string
227
    Model    string
228
    APIKey   string
229
    Username string // For logging purposes
230
}
231

232
// StoryGenerationEligibilityResponse represents the result of checking if a story section can be generated
233
type StoryGenerationEligibilityResponse struct {
234
    CanGenerate bool   `json:"can_generate"`
235
    Reason      string `json:"reason,omitempty"`
236
    Story       *Story `json:"story,omitempty"` // Include story data when needed for additional checks
237
}
238

239
// GetSectionLengthTarget returns the target word count for a story section
240
func GetSectionLengthTarget(level string, lengthPref *SectionLength) int {
241
    // Map CEFR levels to generic proficiency levels for backward compatibility
242
    levelMapping := map[string]string{
243
        "A1": "beginner",
244
        "A2": "elementary",
245
        "B1": "intermediate",
246
        "B2": "upper_intermediate",
247
        "C1": "advanced",
248
        "C2": "proficient",
249
    }
250

251
    genericLevel := levelMapping[level]
252
    if genericLevel == "" {
253
        // If no mapping found, default to intermediate
254
        genericLevel = "intermediate"
255
    }
256

257
    // Default length targets by proficiency level (in words)
258
    lengthTargets := map[string]map[SectionLength]int{
259
        "beginner":           {SectionLengthShort: 50, SectionLengthMedium: 80, SectionLengthLong: 120},
260
        "elementary":         {SectionLengthShort: 80, SectionLengthMedium: 120, SectionLengthLong: 180},
261
        "intermediate":       {SectionLengthShort: 150, SectionLengthMedium: 220, SectionLengthLong: 300},
262
        "upper_intermediate": {SectionLengthShort: 250, SectionLengthMedium: 350, SectionLengthLong: 450},
263
        "advanced":           {SectionLengthShort: 350, SectionLengthMedium: 500, SectionLengthLong: 650},
264
        "proficient":         {SectionLengthShort: 500, SectionLengthMedium: 700, SectionLengthLong: 900},
265
    }
266

267
    levelTargets, exists := lengthTargets[genericLevel]
268
    if !exists {
269
        // Default to intermediate if level not found
270
        levelTargets = lengthTargets["intermediate"]
271
    }
272

273
    if lengthPref != nil {
274
        if target, exists := levelTargets[*lengthPref]; exists {
275
            return target
276
        }
277
    }
278

279
    // Default to medium length
280
    return levelTargets[SectionLengthMedium]
281
}
282


			
quizapp internal models word_of_the_day.go
0.0%
Statements
0/1
1
package models
2

3
import (
4
    "encoding/json"
5
    "time"
6
)
7

8
// WordSourceType represents the type of source for the word of the day
9
type WordSourceType string
10

11
const (
12
    // WordSourceVocabularyQuestion represents a word from a vocabulary question
13
    WordSourceVocabularyQuestion WordSourceType = "vocabulary_question"
14
    // WordSourceSnippet represents a word from a user snippet
15
    WordSourceSnippet WordSourceType = "snippet"
16
)
17

18
// WordOfTheDay represents a daily word assignment for a user
19
type WordOfTheDay struct {
20
    ID             int            `json:"id" db:"id"`
21
    UserID         int            `json:"user_id" db:"user_id"`
22
    AssignmentDate time.Time      `json:"assignment_date" db:"assignment_date"`
23
    SourceType     WordSourceType `json:"source_type" db:"source_type"`
24
    SourceID       int            `json:"source_id" db:"source_id"`
25
    CreatedAt      time.Time      `json:"created_at" db:"created_at"`
26
}
27

28
// WordOfTheDayWithContent represents a word of the day with full content details
29
type WordOfTheDayWithContent struct {
30
    WordOfTheDay
31
    // Question is populated when SourceType is WordSourceVocabularyQuestion
32
    Question *Question `json:"question,omitempty"`
33
    // Snippet is populated when SourceType is WordSourceSnippet
34
    Snippet *Snippet `json:"snippet,omitempty"`
35
}
36

37
// WordOfTheDayDisplay represents the simplified display format for word of the day
38
// This is used for API responses and contains the essential information
39
type WordOfTheDayDisplay struct {
40
    Date          time.Time      `json:"date"`
41
    Word          string         `json:"word"`
42
    Translation   string         `json:"translation"`
43
    Sentence      string         `json:"sentence"`
44
    SourceType    WordSourceType `json:"source_type"`
45
    SourceID      int            `json:"source_id"`
46
    Language      string         `json:"language"`
47
    Level         string         `json:"level,omitempty"`
48
    Context       string         `json:"context,omitempty"`
49
    Explanation   string         `json:"explanation,omitempty"`
50
    TopicCategory string         `json:"topic_category,omitempty"`
51
}
52

53
// MarshalJSON customizes JSON marshaling for WordOfTheDayDisplay to format the date field as YYYY-MM-DD
54
// This ensures compliance with OpenAPI date format (not date-time)
55
func (w WordOfTheDayDisplay) MarshalJSON() ([]byte, error) {
56
    return json.Marshal(&struct {
57
        Date          string         `json:"date"`
58
        Word          string         `json:"word"`
59
        Translation   string         `json:"translation"`
60
        Sentence      string         `json:"sentence"`
61
        SourceType    WordSourceType `json:"source_type"`
62
        SourceID      int            `json:"source_id"`
63
        Language      string         `json:"language"`
64
        Level         string         `json:"level,omitempty"`
65
        Context       string         `json:"context,omitempty"`
66
        Explanation   string         `json:"explanation,omitempty"`
67
        TopicCategory string         `json:"topic_category,omitempty"`
68
    }{
69
        Date:          w.Date.UTC().Format("2006-01-02"),
70
        Word:          w.Word,
71
        Translation:   w.Translation,
72
        Sentence:      w.Sentence,
73
        SourceType:    w.SourceType,
74
        SourceID:      w.SourceID,
75
        Language:      w.Language,
76
        Level:         w.Level,
77
        Context:       w.Context,
78
        Explanation:   w.Explanation,
79
        TopicCategory: w.TopicCategory,
80
    })
81
}
82


			
quizapp internal observability
56.6%
Statements
138/244
global_tracer.go
2.2%
1/45
logging.go
72.5%
50/69
metrics.go
54.2%
13/24
middleware.go
61.9%
26/42
setup.go
83.9%
26/31
span_helpers.go
0.0%
0/6
tracing.go
81.5%
22/27
quizapp internal observability tracing.go
2.2%
Statements
1/45
1
package observability
2

3
import (
4
    "context"
5
    "fmt"
6

7
    "quizapp/internal/models"
8

9
    "go.opentelemetry.io/otel"
10
    "go.opentelemetry.io/otel/attribute"
11
    "go.opentelemetry.io/otel/trace"
12
)
13

14
var globalTracer trace.Tracer
15

16
// InitGlobalTracer initializes the global tracer for the application.
17
6x
func InitGlobalTracer() {
18
6x
    globalTracer = otel.Tracer("quiz-app")
19
6x
}
20

21
// GetGlobalTracer returns the global tracer instance for the application.
22
func GetGlobalTracer() trace.Tracer {
23
    if globalTracer == nil {
24
        // Fallback to default tracer if not initialized
25
        globalTracer = otel.Tracer("quiz-app")
26
    }
27
    return globalTracer
28
}
29

30
// TraceFunction starts a new span with a descriptive name for the given service and function.
31
func TraceFunction(ctx context.Context, serviceName, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
32
    tracer := GetGlobalTracer()
33
    spanName := fmt.Sprintf("%s.%s", serviceName, functionName)
34
    return tracer.Start(ctx, spanName, trace.WithAttributes(attributes...))
35
}
36

37
// TraceFunctionWithErrorHandling starts a new span and automatically adds error attributes if the function panics or returns an error.
38
func TraceFunctionWithErrorHandling(ctx context.Context, serviceName, functionName string, fn func() error, attributes ...attribute.KeyValue) error {
39
    _, span := TraceFunction(ctx, serviceName, functionName, attributes...)
40
    defer func() {
41
        if err := recover(); err != nil {
42
            span.SetAttributes(
43
                attribute.Bool("error", true),
44
                attribute.String("error.type", "panic"),
45
                attribute.String("error.message", fmt.Sprintf("%v", err)),
46
            )
47
            span.End()
48
            panic(err) // re-panic
49
        }
50
    }()
51

52
    err := fn()
53
    if err != nil {
54
        span.SetAttributes(
55
            attribute.Bool("error", true),
56
            attribute.String("error.message", err.Error()),
57
        )
58
    }
59
    span.End()
60
    return err
61
}
62

63
// TraceSnippetFunction starts a new span for a snippet service function.
64
func TraceSnippetFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
65
    return TraceFunction(ctx, "snippet", functionName, attributes...)
66
}
67

68
// TraceTranslationFunction starts a new span for a translation service function.
69
func TraceTranslationFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
70
    return TraceFunction(ctx, "translation", functionName, attributes...)
71
}
72

73
// TraceAIFunction starts a new span for an AI service function.
74
func TraceAIFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
75
    return TraceFunction(ctx, "ai", functionName, attributes...)
76
}
77

78
// TraceUserFunction starts a new span for a user service function.
79
func TraceUserFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
80
    return TraceFunction(ctx, "user", functionName, attributes...)
81
}
82

83
// TraceQuestionFunction starts a new span for a question service function.
84
func TraceQuestionFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
85
    return TraceFunction(ctx, "question", functionName, attributes...)
86
}
87

88
// TraceWorkerFunction starts a new span for a worker service function.
89
func TraceWorkerFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
90
    return TraceFunction(ctx, "worker", functionName, attributes...)
91
}
92

93
// TraceLearningFunction starts a new span for a learning service function.
94
func TraceLearningFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
95
    return TraceFunction(ctx, "learning", functionName, attributes...)
96
}
97

98
// TraceHandlerFunction starts a new span for a handler function.
99
func TraceHandlerFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
100
    return TraceFunction(ctx, "handler", functionName, attributes...)
101
}
102

103
// TraceVarietyFunction starts a new span for a variety service function.
104
func TraceVarietyFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
105
    return TraceFunction(ctx, "variety", functionName, attributes...)
106
}
107

108
// TraceOAuthFunction starts a new span for an OAuth service function.
109
func TraceOAuthFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
110
    return TraceFunction(ctx, "oauth", functionName, attributes...)
111
}
112

113
// TraceUsageStatsFunction starts a new span for a usage stats service function.
114
func TraceUsageStatsFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
115
    return TraceFunction(ctx, "usage_stats", functionName, attributes...)
116
}
117

118
// TraceCleanupFunction starts a new span for a cleanup service function.
119
func TraceCleanupFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
120
    return TraceFunction(ctx, "cleanup", functionName, attributes...)
121
}
122

123
// TraceDatabaseFunction starts a new span for a database function.
124
func TraceDatabaseFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
125
    return TraceFunction(ctx, "database", functionName, attributes...)
126
}
127

128
// AttributeQuestion returns a tracing attribute for a question's ID.
129
func AttributeQuestion(q *models.Question) attribute.KeyValue {
130
    return attribute.String("question.id", fmt.Sprintf("%d", q.ID))
131
}
132

133
// AttributeQuestionID returns a tracing attribute for a question ID.
134
func AttributeQuestionID(id int) attribute.KeyValue {
135
    return attribute.Int("question.id", id)
136
}
137

138
// AttributeUserID returns a tracing attribute for a user ID.
139
func AttributeUserID(id int) attribute.KeyValue {
140
    return attribute.Int("user.id", id)
141
}
142

143
// AttributeSnippetID returns a tracing attribute for a snippet ID.
144
func AttributeSnippetID(id int) attribute.KeyValue {
145
    return attribute.Int("snippet.id", id)
146
}
147

148
// AttributeLanguage returns a tracing attribute for a language.
149
func AttributeLanguage(lang string) attribute.KeyValue {
150
    return attribute.String("language", lang)
151
}
152

153
// AttributeGenerationType returns a tracing attribute for a generation type.
154
func AttributeGenerationType(generationType models.GeneratorType) attribute.KeyValue {
155
    return attribute.String("generation_type", string(generationType))
156
}
157

158
// AttributeLevel returns a tracing attribute for a level.
159
func AttributeLevel(level string) attribute.KeyValue {
160
    return attribute.String("level", level)
161
}
162

163
// AttributeQuestionType returns a tracing attribute for a question type.
164
func AttributeQuestionType(qType interface{}) attribute.KeyValue {
165
    return attribute.String("question.type", fmt.Sprintf("%v", qType))
166
}
167

168
// AttributeLimit returns a tracing attribute for a limit value.
169
func AttributeLimit(limit int) attribute.KeyValue {
170
    return attribute.Int("limit", limit)
171
}
172

173
// AttributePage returns a tracing attribute for a page value.
174
func AttributePage(page int) attribute.KeyValue {
175
    return attribute.Int("page", page)
176
}
177

178
// AttributePageSize returns a tracing attribute for a page size value.
179
func AttributePageSize(size int) attribute.KeyValue {
180
    return attribute.Int("page_size", size)
181
}
182

183
// AttributeSearch returns a tracing attribute for a search value.
184
func AttributeSearch(search string) attribute.KeyValue {
185
    return attribute.String("search", search)
186
}
187

188
// AttributeTypeFilter returns a tracing attribute for a type filter value.
189
func AttributeTypeFilter(typeFilter string) attribute.KeyValue {
190
    return attribute.String("type_filter", typeFilter)
191
}
192

193
// AttributeStatusFilter returns a tracing attribute for a status filter value.
194
func AttributeStatusFilter(statusFilter string) attribute.KeyValue {
195
    return attribute.String("status_filter", statusFilter)
196
}
197


			
quizapp internal observability tracing.go
72.5%
Statements
50/69
1
// Package observability provides OpenTelemetry tracing, metrics, and structured logging
2
// with trace correlation for the quiz application.
3
package observability
4

5
import (
6
    "context"
7
    "os"
8

9
    "quizapp/internal/config"
10

11
    "go.opentelemetry.io/contrib/bridges/otelzap"
12
    "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
13
    "go.opentelemetry.io/otel/sdk/log"
14
    "go.opentelemetry.io/otel/sdk/resource"
15
    semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
16
    "go.opentelemetry.io/otel/trace"
17
    "go.uber.org/zap"
18
    "go.uber.org/zap/zapcore"
19
)
20

21
// Logger wraps the zap logger with OpenTelemetry context support
22
type Logger struct {
23
    *zap.Logger
24
}
25

26
// NewLogger creates a new logger with OpenTelemetry context support and OTLP export
27
8x
func NewLogger(cfg *config.OpenTelemetryConfig) *Logger {
28
8x
    return NewLoggerWithLevel(cfg, zap.InfoLevel)
29
8x
}
30

31
// NewLoggerWithLevel creates a new logger with OpenTelemetry context support and OTLP export
32
8x
func NewLoggerWithLevel(cfg *config.OpenTelemetryConfig, level zapcore.Level) *Logger {
33
8x
    // If logging is disabled, return a no-op logger
34
8x
    if cfg == nil || !cfg.EnableLogging {
35
6x
        return &Logger{Logger: zap.NewNop()}
36
6x
    }
37

38
    // Create a basic zap logger for stdout
39
2x
    zapConfig := zap.NewProductionConfig()
40
2x
    zapConfig.Level = zap.NewAtomicLevelAt(level)
41
2x
    zapConfig.EncoderConfig.TimeKey = "timestamp"
42
2x
    zapConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
43
2x
    zapConfig.EncoderConfig.StacktraceKey = "stacktrace"
44
2x

45
2x
    // Use development config if in development mode
46
2x
    if os.Getenv("ENV") == "development" {
47
        zapConfig = zap.NewDevelopmentConfig()
48
        zapConfig.Level = zap.NewAtomicLevelAt(level)
49
    }
50

51
2x
    zapLogger, err := zapConfig.Build()
52
2x
    if err != nil {
53
        // Fallback to a basic logger if config fails
54
        zapLogger = zap.NewExample()
55
    }
56

57
    // If OTLP logging is enabled, set up the OTLP exporter
58
2x
    if cfg.EnableLogging && cfg.Endpoint != "" {
59
1x
        // Log that we're attempting to set up OTLP export
60
1x
        zapLogger.Info("Setting up OTLP logging", zap.String("endpoint", cfg.Endpoint), zap.String("protocol", cfg.Protocol))
61
1x

62
1x
        // Create OTLP exporter with proper endpoint format
63
1x
        endpoint := cfg.Endpoint
64
1x

65
1x
        // Set up resource attributes
66
1x
        res, err := resource.New(context.Background(),
67
1x
            resource.WithAttributes(
68
1x
                semconv.ServiceName(cfg.ServiceName),
69
1x
                semconv.ServiceVersion(cfg.ServiceVersion),
70
1x
            ),
71
1x
        )
72
1x
        if err != nil {
73
            // Log the error but continue with stdout logging
74
            zapLogger.Error("Failed to create otel resource", zap.Error(err))
75
        } else {
76
1x
            exporter, err := otlploggrpc.New(context.Background(),
77
1x
                otlploggrpc.WithEndpoint(endpoint),
78
1x
                otlploggrpc.WithInsecure(),
79
1x
            )
80
1x
            if err != nil {
81
                // Log the error but continue with stdout logging
82
                zapLogger.Error("Failed to create OTLP exporter", zap.Error(err), zap.String("endpoint", endpoint))
83
            } else {
84
1x
                zapLogger.Info("Successfully created OTLP exporter", zap.String("endpoint", endpoint))
85
1x

86
1x
                // Create batch processor
87
1x
                processor := log.NewBatchProcessor(exporter)
88
1x

89
1x
                // Create logger provider with resource
90
1x
                provider := log.NewLoggerProvider(
91
1x
                    log.WithProcessor(processor),
92
1x
                    log.WithResource(res),
93
1x
                )
94
1x

95
1x
                // Create OpenTelemetry core
96
1x
                otelCore := otelzap.NewCore("quizapp", otelzap.WithLoggerProvider(provider))
97
1x

98
1x
                // Create a new zap logger with both stdout and OTLP cores
99
1x
                cores := []zapcore.Core{
100
1x
                    zapLogger.Core(),
101
1x
                    otelCore,
102
1x
                }
103
1x

104
1x
                // Create a new logger with multiple cores
105
1x
                multiCore := zapcore.NewTee(cores...)
106
1x
                zapLogger = zap.New(multiCore)
107
1x

108
1x
                zapLogger.Info("OTLP logging successfully configured", zap.String("endpoint", endpoint))
109
1x
            }
110
        }
111
1x
    } else {
112
1x
        zapLogger.Info("OTLP logging not enabled", zap.Bool("enable_logging", cfg.EnableLogging), zap.String("endpoint", cfg.Endpoint))
113
1x
    }
114

115
2x
    return &Logger{Logger: zapLogger}
116
}
117

118
// Debug logs a debug message with context
119
func (l *Logger) Debug(ctx context.Context, msg string, fields ...map[string]interface{}) {
120
    l.logWithContext(ctx, zap.DebugLevel, msg, fields...)
121
}
122

123
// Info logs an info message with context
124
10x
func (l *Logger) Info(ctx context.Context, msg string, fields ...map[string]interface{}) {
125
10x
    l.logWithContext(ctx, zap.InfoLevel, msg, fields...)
126
10x
}
127

128
// Warn logs a warning message with context
129
func (l *Logger) Warn(ctx context.Context, msg string, fields ...map[string]interface{}) {
130
    l.logWithContext(ctx, zap.WarnLevel, msg, fields...)
131
}
132

133
// Error logs an error message with context
134
1x
func (l *Logger) Error(ctx context.Context, msg string, err error, fields ...map[string]interface{}) {
135
1x
    // Merge fields with error information
136
1x
    allFields := l.mergeFields(fields...)
137
1x
    if err != nil {
138
        allFields["error"] = err.Error()
139
    }
140
1x
    l.logWithContext(ctx, zap.ErrorLevel, msg, allFields)
141
}
142

143
// logWithContext logs a message with OpenTelemetry context correlation
144
11x
func (l *Logger) logWithContext(ctx context.Context, level zapcore.Level, msg string, fields ...map[string]interface{}) {
145
11x
    // Merge all fields into a single map
146
11x
    allFields := l.mergeFields(fields...)
147
11x

148
11x
    // Add trace context if available
149
11x
    if span := trace.SpanFromContext(ctx); span != nil {
150
11x
        spanContext := span.SpanContext()
151
11x
        if spanContext.IsValid() {
152
1x
            allFields["trace_id"] = spanContext.TraceID().String()
153
1x
            allFields["span_id"] = spanContext.SpanID().String()
154
1x
        }
155
    }
156

157
    // Convert fields to zap fields
158
11x
    zapFields := make([]zap.Field, 0, len(allFields))
159
11x
    for k, v := range allFields {
160
8x
        zapFields = append(zapFields, zap.Any(k, v))
161
8x
    }
162

163
    // Log with the appropriate level
164
11x
    switch level {
165
    case zap.DebugLevel:
166
        l.Logger.Debug(msg, zapFields...)
167
10x
    case zap.InfoLevel:
168
10x
        l.Logger.Info(msg, zapFields...)
169
    case zap.WarnLevel:
170
        l.Logger.Warn(msg, zapFields...)
171
1x
    case zap.ErrorLevel:
172
1x
        l.Logger.Error(msg, zapFields...)
173
    default:
174
        l.Logger.Info(msg, zapFields...)
175
    }
176
}
177

178
// mergeFields merges multiple field maps into a single map
179
12x
func (l *Logger) mergeFields(fields ...map[string]interface{}) map[string]interface{} {
180
12x
    if len(fields) == 0 {
181
3x
        return map[string]interface{}{}
182
3x
    }
183

184
9x
    if len(fields) == 1 {
185
9x
        // Handle nil field map
186
9x
        if fields[0] == nil {
187
2x
            return map[string]interface{}{}
188
2x
        }
189
7x
        return fields[0]
190
    }
191

192
    // Merge multiple field maps
193
    merged := make(map[string]interface{})
194
    for _, fieldMap := range fields {
195
        // Skip nil field maps
196
        if fieldMap == nil {
197
            continue
198
        }
199
        for k, v := range fieldMap {
200
            merged[k] = v
201
        }
202
    }
203
    return merged
204
}
205

206
// Sync flushes any buffered log entries
207
func (l *Logger) Sync() error {
208
    return l.Logger.Sync()
209
}
210


			
quizapp internal observability tracing.go
54.2%
Statements
13/24
1
package observability
2

3
import (
4
    "context"
5

6
    "quizapp/internal/config"
7
    contextutils "quizapp/internal/utils"
8

9
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
10
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
11
    "go.opentelemetry.io/otel/sdk/metric"
12
    "go.opentelemetry.io/otel/sdk/resource"
13
    semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
14
)
15

16
// InitMetrics initializes OpenTelemetry metrics
17
1x
func InitMetrics(cfg *config.OpenTelemetryConfig) (result0 *metric.MeterProvider, err error) {
18
1x
    ctx := context.Background()
19
1x

20
1x
    // Set up resource attributes
21
1x
    res, err := resource.New(ctx,
22
1x
        resource.WithAttributes(
23
1x
            semconv.ServiceName(cfg.ServiceName),
24
1x
            semconv.ServiceVersion(cfg.ServiceVersion),
25
1x
        ),
26
1x
    )
27
1x
    if err != nil {
28
        return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otel resource: %w", err)
29
    }
30

31
    // Set up exporter
32
1x
    var exporter metric.Exporter
33
1x
    switch cfg.Protocol {
34
1x
    case "grpc":
35
1x
        // For gRPC, strip http:// prefix if present, otherwise use endpoint as-is
36
1x
        endpoint := cfg.Endpoint
37
1x
        exp, err := otlpmetricgrpc.New(ctx,
38
1x
            otlpmetricgrpc.WithEndpoint(endpoint),
39
1x
            func() otlpmetricgrpc.Option {
40
1x
                if cfg.Insecure {
41
1x
                    return otlpmetricgrpc.WithInsecure()
42
1x
                }
43
                return nil
44
            }(),
45
            otlpmetricgrpc.WithHeaders(cfg.Headers),
46
        )
47
1x
        if err != nil {
48
            return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otlp grpc metric exporter: %w", err)
49
        }
50
1x
        exporter = exp
51
    case "http":
52
        exp, err := otlpmetrichttp.New(ctx,
53
            otlpmetrichttp.WithEndpoint(cfg.Endpoint),
54
            func() otlpmetrichttp.Option {
55
                if cfg.Insecure {
56
                    return otlpmetrichttp.WithInsecure()
57
                }
58
                return nil
59
            }(),
60
            otlpmetrichttp.WithHeaders(cfg.Headers),
61
        )
62
        if err != nil {
63
            return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otlp http metric exporter: %w", err)
64
        }
65
        exporter = exp
66
    default:
67
        return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "unsupported otel protocol: %s", cfg.Protocol)
68
    }
69

70
    // Set up meter provider
71
1x
    mp := metric.NewMeterProvider(
72
1x
        metric.WithReader(metric.NewPeriodicReader(exporter)),
73
1x
        metric.WithResource(res),
74
1x
    )
75
1x
    return mp, nil
76
}
77


			
quizapp internal observability tracing.go
61.9%
Statements
26/42
1
package observability
2

3
import (
4
    "errors"
5

6
    "github.com/gin-contrib/sessions"
7
    "github.com/gin-gonic/gin"
8
    "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
9
    "go.opentelemetry.io/otel/attribute"
10
    "go.opentelemetry.io/otel/codes"
11
    "go.opentelemetry.io/otel/trace"
12

13
    contextutils "quizapp/internal/utils"
14
)
15

16
// GinMiddleware creates OpenTelemetry middleware for Gin HTTP requests
17
3x
func GinMiddleware(serviceName string) gin.HandlerFunc {
18
3x
    return otelgin.Middleware(serviceName)
19
3x
}
20

21
// GinMiddlewareWithErrorHandling creates OpenTelemetry middleware with automatic error attribute addition and detailed logging
22
3x
func GinMiddlewareWithErrorHandling(serviceName string) gin.HandlerFunc {
23
3x
    return func(c *gin.Context) {
24
9x
        // Use the existing OpenTelemetry middleware
25
9x
        otelgin.Middleware(serviceName)(c)
26
9x

27
9x
        // After the request is processed, check for errors
28
9x
        c.Next()
29
9x

30
9x
        // Get the span from context and add error attributes for failed requests
31
9x
        if span := trace.SpanFromContext(c.Request.Context()); span != nil {
32
9x
            statusCode := c.Writer.Status()
33
9x
            if statusCode >= 400 {
34
6x
                // Determine error severity based on status code and error types
35
6x
                severity := determineErrorSeverity(statusCode, c.Errors)
36
6x

37
6x
                // Create a more descriptive error message based on status code
38
6x
                var errorMsg string
39
6x
                switch {
40
2x
                case statusCode >= 500:
41
2x
                    errorMsg = "server error"
42
4x
                case statusCode >= 400:
43
4x
                    errorMsg = "client error"
44
                default:
45
                    errorMsg = "request failed"
46
                }
47

48
                // Add error details from Gin's error context if available
49
6x
                if len(c.Errors) > 0 {
50
                    for _, err := range c.Errors {
51
                        if appErr, ok := err.Err.(*contextutils.AppError); ok {
52
                            errorMsg = appErr.Message
53
                            severity = string(appErr.Severity)
54
                            break
55
                        }
56
                        errorMsg = err.Error()
57
                    }
58
                }
59

60
                // Record the error with stack trace
61
6x
                span.RecordError(errors.New(errorMsg), trace.WithStackTrace(true))
62
6x
                span.SetStatus(codes.Error, errorMsg)
63
6x

64
6x
                // Add additional attributes for better debugging
65
6x
                span.SetAttributes(
66
6x
                    attribute.Int("http.status_code", statusCode),
67
6x
                    attribute.String("http.method", c.Request.Method),
68
6x
                    attribute.String("http.path", c.Request.URL.Path),
69
6x
                    attribute.String("error.handler", c.HandlerName()),
70
6x
                    attribute.String("error.severity", severity),
71
6x
                )
72
6x

73
6x
                // Add user context if available
74
6x
                session := sessions.Default(c)
75
6x
                if userID, ok := session.Get("user_id").(int); ok {
76
                    span.SetAttributes(attribute.Int("error.user_id", userID))
77
                }
78

79
                // Add request body size for debugging
80
6x
                if c.Request.ContentLength > 0 {
81
                    span.SetAttributes(attribute.Int64("error.request_size", c.Request.ContentLength))
82
                }
83

84
                // Add specific error attributes based on error types
85
6x
                if len(c.Errors) > 0 {
86
                    for _, err := range c.Errors {
87
                        if appErr, ok := err.Err.(*contextutils.AppError); ok {
88
                            span.SetAttributes(
89
                                attribute.String("error.code", string(appErr.Code)),
90
                                attribute.Bool("error.retryable", contextutils.IsRetryable(appErr)),
91
                            )
92
                            break
93
                        }
94
                    }
95
                }
96

97
                // Add server error specific attributes
98
6x
                if statusCode >= 500 {
99
2x
                    span.SetAttributes(
100
2x
                        attribute.Bool("error.server_error", true),
101
2x
                    )
102
2x
                }
103
            }
104
        }
105
    }
106
}
107

108
// determineErrorSeverity determines the severity level based on status code and error types
109
6x
func determineErrorSeverity(statusCode int, errors []*gin.Error) string {
110
6x
    // Check for AppError types first
111
6x
    for _, err := range errors {
112
        if appErr, ok := err.Err.(*contextutils.AppError); ok {
113
            return string(appErr.Severity)
114
        }
115
    }
116

117
    // Fallback to status code based severity
118
6x
    switch {
119
2x
    case statusCode >= 500:
120
2x
        return string(contextutils.SeverityError)
121
4x
    case statusCode >= 400:
122
4x
        return string(contextutils.SeverityWarn)
123
    default:
124
        return string(contextutils.SeverityInfo)
125
    }
126
}
127


			
quizapp internal observability tracing.go
83.9%
Statements
26/31
1
package observability
2

3
import (
4
    "context"
5
    "os"
6

7
    "quizapp/internal/config"
8

9
    autosdk "go.opentelemetry.io/auto/sdk"
10
    "go.opentelemetry.io/otel"
11
    "go.opentelemetry.io/otel/sdk/metric"
12
    "go.opentelemetry.io/otel/trace"
13
)
14

15
// SetupObservability initializes tracing, metrics, and logging for a service
16
7x
func SetupObservability(cfg *config.OpenTelemetryConfig, serviceName string) (result0 trace.TracerProvider, result1 *metric.MeterProvider, result2 *Logger, err error) {
17
7x
    if serviceName != "" {
18
7x
        cfg.ServiceName = serviceName
19
7x
    }
20

21
7x
    var tp trace.TracerProvider
22
7x
    var mp *metric.MeterProvider
23
7x
    var logger *Logger
24
7x

25
7x
    if err := os.Setenv("OTEL_SERVICE_NAME", cfg.ServiceName); err != nil {
26
        return nil, nil, nil, err
27
    }
28
7x
    if err := os.Setenv("OTEL_SERVICE_VERSION", cfg.ServiceVersion); err != nil {
29
        return nil, nil, nil, err
30
    }
31

32
7x
    if cfg.EnableLogging {
33
1x
        logger = NewLogger(cfg)
34
1x
    } else {
35
6x
        // Return a no-op logger when logging is disabled
36
6x
        logger = NewLogger(&config.OpenTelemetryConfig{EnableLogging: false})
37
6x
    }
38

39
7x
    if cfg.EnableTracing {
40
6x
        if cfg.UseAutoSDK {
41
2x
            // Use Auto SDK (default behavior, compatible with OBI)
42
2x
            tp = autosdk.TracerProvider()
43
2x
            otel.SetTracerProvider(tp)
44
2x

45
2x
            logger.Info(context.Background(), "Tracing enabled with Auto SDK", map[string]interface{}{"service_name": cfg.ServiceName})
46
2x
        } else {
47
4x
            // Use standard OpenTelemetry SDK with OTLP exporter
48
4x
            tp, err = InitStandardTracing(cfg)
49
4x
            if err != nil {
50
                panic(err)
51
            }
52
4x
            otel.SetTracerProvider(tp)
53
4x

54
4x
            logger.Info(context.Background(), "Tracing enabled with standard SDK", map[string]interface{}{"service_name": cfg.ServiceName})
55
        }
56

57
6x
        err := InitTracing(cfg)
58
6x
        if err != nil {
59
            panic(err)
60
        }
61

62
        // Initialize the global tracer
63
6x
        InitGlobalTracer()
64
    }
65

66
7x
    if cfg.EnableMetrics {
67
1x
        mp, err = InitMetrics(cfg)
68
1x
        if err != nil {
69
            panic(err)
70
        }
71
    }
72

73
7x
    return tp, mp, logger, nil
74
}
75


			
quizapp internal observability tracing.go
0.0%
Statements
0/6
1
package observability
2

3
import (
4
    "go.opentelemetry.io/otel/codes"
5
    "go.opentelemetry.io/otel/trace"
6
)
7

8
// FinishSpan ends a span and records any error pointed to by errPtr.
9
// Use with a named error return: `defer observability.FinishSpan(span, &err)`
10
func FinishSpan(span trace.Span, errPtr *error) {
11
    if span == nil {
12
        return
13
    }
14
    if errPtr != nil && *errPtr != nil {
15
        span.RecordError(*errPtr, trace.WithStackTrace(true))
16
        span.SetStatus(codes.Error, (*errPtr).Error())
17
    }
18
    span.End()
19
}
20


			
quizapp internal observability tracing.go
81.5%
Statements
22/27
1
package observability
2

3
import (
4
    "context"
5

6
    "quizapp/internal/config"
7
    contextutils "quizapp/internal/utils"
8

9
    "go.opentelemetry.io/otel"
10
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
11
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
12
    "go.opentelemetry.io/otel/propagation"
13
    "go.opentelemetry.io/otel/sdk/resource"
14
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
15
    semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
16
    "go.opentelemetry.io/otel/trace"
17
)
18

19
// InitTracing initializes OpenTelemetry tracing
20
6x
func InitTracing(_ *config.OpenTelemetryConfig) (err error) {
21
6x
    // ctx := context.Background()
22
6x

23
6x
    // // Set up resource attributes
24
6x
    // res, err := resource.New(ctx,
25
6x
    //     resource.WithAttributes(
26
6x
    //         semconv.ServiceName(cfg.ServiceName),
27
6x
    //         semconv.ServiceVersion(cfg.ServiceVersion),
28
6x
    //     ),
29
6x
    // )
30
6x
    // if err != nil {
31
6x
    //     return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otel resource: %w", err)
32
6x
    // }
33
6x

34
6x
    // Set up exporter
35
6x
    // var exporter trace.SpanExporter
36
6x
    // switch cfg.Protocol {
37
6x
    // case "grpc":
38
6x
    //     // For gRPC, strip http:// prefix if present, otherwise use endpoint as-is
39
6x
    //     endpoint := cfg.Endpoint
40
6x
    //     exp, err := otlptracegrpc.New(ctx,
41
6x
    //         otlptracegrpc.WithEndpoint(endpoint),
42
6x
    //         func() otlptracegrpc.Option {
43
6x
    //             if cfg.Insecure {
44
6x
    //                 return otlptracegrpc.WithInsecure()
45
6x
    //             }
46
6x
    //             return nil
47
6x
    //         }(),
48
6x
    //         otlptracegrpc.WithHeaders(cfg.Headers),
49
6x
    //     )
50
6x
    //     if err != nil {
51
6x
    //         return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otlp grpc exporter: %w", err)
52
6x
    //     }
53
6x
    //     exporter = exp
54
6x
    // case "http":
55
6x
    //     exp, err := otlptracehttp.New(ctx,
56
6x
    //         otlptracehttp.WithEndpoint(cfg.Endpoint),
57
6x
    //         otlptracehttp.WithInsecure(),
58
6x
    //         otlptracehttp.WithHeaders(cfg.Headers),
59
6x
    //     )
60
6x
    //     if err != nil {
61
6x
    //         return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otlp http exporter: %w", err)
62
6x
    //     }
63
6x
    //     exporter = exp
64
6x
    // default:
65
6x
    //     return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "unsupported otel protocol: %s", cfg.Protocol)
66
6x
    // }
67
6x

68
6x
    // Set up sampler
69
6x
    // sampler := trace.ParentBased(trace.TraceIDRatioBased(cfg.SamplingRate))
70
6x

71
6x
    // // Set up tracer provider
72
6x
    // tp := trace.NewTracerProvider(
73
6x
    //     trace.WithBatcher(exporter),
74
6x
    //     trace.WithResource(res),
75
6x
    //     trace.WithSampler(sampler),
76
6x
    // )
77
6x
    // otel.SetTracerProvider(tp)
78
6x

79
6x
    // Set up text map propagator for trace context propagation
80
6x
    // This enables the backend to receive and process trace headers from NGINX
81
6x
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
82
6x
        propagation.TraceContext{},
83
6x
        propagation.Baggage{},
84
6x
    ))
85
6x

86
6x
    return nil
87
6x
}
88

89
// InitStandardTracing initializes a standard OpenTelemetry SDK TracerProvider with OTLP exporter
90
7x
func InitStandardTracing(cfg *config.OpenTelemetryConfig) (result0 trace.TracerProvider, err error) {
91
7x
    ctx := context.Background()
92
7x

93
7x
    // Set up resource attributes
94
7x
    res, err := resource.New(ctx,
95
7x
        resource.WithAttributes(
96
7x
            semconv.ServiceName(cfg.ServiceName),
97
7x
            semconv.ServiceVersion(cfg.ServiceVersion),
98
7x
        ),
99
7x
    )
100
7x
    if err != nil {
101
        return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otel resource: %w", err)
102
    }
103

104
    // Set up exporter
105
7x
    var exporter sdktrace.SpanExporter
106
7x
    switch cfg.Protocol {
107
5x
    case "grpc":
108
5x
        // For gRPC, strip http:// prefix if present, otherwise use endpoint as-is
109
5x
        endpoint := cfg.Endpoint
110
5x
        exp, err := otlptracegrpc.New(ctx,
111
5x
            otlptracegrpc.WithEndpoint(endpoint),
112
5x
            func() otlptracegrpc.Option {
113
5x
                if cfg.Insecure {
114
5x
                    return otlptracegrpc.WithInsecure()
115
5x
                }
116
                return nil
117
            }(),
118
            otlptracegrpc.WithHeaders(cfg.Headers),
119
        )
120
5x
        if err != nil {
121
            return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otlp grpc exporter: %w", err)
122
        }
123
5x
        exporter = exp
124
1x
    case "http":
125
1x
        exp, err := otlptracehttp.New(ctx,
126
1x
            otlptracehttp.WithEndpoint(cfg.Endpoint),
127
1x
            func() otlptracehttp.Option {
128
1x
                if cfg.Insecure {
129
1x
                    return otlptracehttp.WithInsecure()
130
1x
                }
131
                return nil
132
            }(),
133
            otlptracehttp.WithHeaders(cfg.Headers),
134
        )
135
1x
        if err != nil {
136
            return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otlp http exporter: %w", err)
137
        }
138
1x
        exporter = exp
139
1x
    default:
140
1x
        return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "unsupported otel protocol: %s", cfg.Protocol)
141
    }
142

143
    // Set up sampler
144
6x
    sampler := sdktrace.ParentBased(sdktrace.TraceIDRatioBased(cfg.SamplingRate))
145
6x

146
6x
    // Set up tracer provider
147
6x
    tp := sdktrace.NewTracerProvider(
148
6x
        sdktrace.WithBatcher(exporter),
149
6x
        sdktrace.WithResource(res),
150
6x
        sdktrace.WithSampler(sampler),
151
6x
    )
152
6x

153
6x
    return tp, nil
154
}
155


			
quizapp internal services
57.8%
Statements
4794/8290
ai_service.go
59.8%
579/968
ai_service_templates.go
78.6%
11/14
auth_api_key_service.go
80.5%
132/164
cleanup_service.go
83.5%
86/103
conversation_service.go
45.8%
125/273
daily_question_service.go
62.2%
265/426
email_factory.go
90.9%
10/11
email_service.go
34.5%
49/142
feedback_service.go
0.0%
0/126
generation_hint_service.go
83.3%
25/30
learning_service.go
79.3%
604/762
linear_service.go
81.8%
260/318
no_questions_error.go
50.0%
1/2
oauth_service.go
62.2%
84/135
question_service.go
72.7%
898/1236
snippets_service.go
64.3%
232/361
story_service.go
38.5%
255/663
test_email_service.go
36.5%
23/63
test_utils.go
66.7%
26/39
translation_cache_repository.go
50.0%
32/64
translation_practice_service.go
19.5%
68/348
translation_service.go
22.1%
23/104
usage_stats_service.go
77.0%
137/178
user_service.go
61.8%
490/793
variety_service.go
83.2%
89/107
word_of_the_day_service.go
43.7%
93/213
worker_service.go
30.4%
197/647
quizapp internal services worker_service.go
59.8%
Statements
579/968
1
// Package services provides business logic services for the quiz application.
2
package services
3

4
import (
5
    "bufio"
6
    "bytes"
7
    "context"
8
    "encoding/json"
9
    "fmt"
10
    "io"
11
    "net/http"
12
    "runtime/debug"
13
    "strconv"
14
    "strings"
15
    "sync"
16
    "time"
17

18
    "quizapp/internal/config"
19
    "quizapp/internal/models"
20
    "quizapp/internal/observability"
21
    contextutils "quizapp/internal/utils"
22

23
    "github.com/xeipuuv/gojsonschema"
24
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
25
    "go.opentelemetry.io/otel/attribute"
26
    "go.opentelemetry.io/otel/codes"
27
    "go.opentelemetry.io/otel/trace"
28
)
29

30
// JSON Schema definitions for grammar field
31
// These schemas are used with the 'grammar' field in OpenAI-compatible API requests
32
// to enforce specific JSON structure validation. This ensures that AI models return
33
// exactly the expected format, eliminating parsing errors and improving reliability.
34
//
35
// The grammar field is conditionally included based on provider support (see supportsGrammarField).
36
// Providers that don't support grammar (like Google) will fall back to prompt-based structure guidance.
37
const (
38
    // Single-item schemas for ai-fix (single question objects)
39
    SingleQuestionSchema = `{
40
        "type": "object",
41
        "properties": {
42
            "question": {"type": "string"},
43
            "options": {"type": "array", "items": {"type": "string"}, "minItems": 4, "maxItems": 4},
44
            "correct_answer": {"type": "integer"},
45
            "explanation": {"type": "string"},
46
            "topic": {"type": "string"}
47
        },
48
        "required": ["question", "options", "correct_answer", "explanation"]
49
    }`
50

51
    SingleReadingComprehensionSchema = `{
52
        "type": "object",
53
        "properties": {
54
            "passage": {"type": "string"},
55
            "question": {"type": "string"},
56
            "options": {"type": "array", "items": {"type": "string"}, "minItems": 4, "maxItems": 4},
57
            "correct_answer": {"type": "integer"},
58
            "explanation": {"type": "string"},
59
            "topic": {"type": "string"}
60
        },
61
        "required": ["passage", "question", "options", "correct_answer", "explanation"]
62
    }`
63

64
    SingleVocabularyQuestionSchema = `{
65
        "type": "object",
66
        "properties": {
67
            "sentence": {"type": "string"},
68
            "question": {"type": "string"},
69
            "options": {"type": "array", "items": {"type": "string"}, "minItems": 4, "maxItems": 4},
70
            "correct_answer": {"type": "integer"},
71
            "explanation": {"type": "string"},
72
            "topic": {"type": "string"}
73
        },
74
        "required": ["sentence", "question", "options", "correct_answer", "explanation"]
75
    }`
76

77
    SingleFillBlankSchema = `{
78
        "type": "object",
79
        "properties": {
80
            "sentence": {"type": "string"},
81
            "options": {"type": "array", "items": {"type": "string"}, "minItems": 4, "maxItems": 4},
82
            "correct_answer": {"type": "integer"},
83
            "explanation": {"type": "string"},
84
            "topic": {"type": "string"},
85
            "hint": {"type": "string"}
86
        },
87
        "required": ["sentence", "options", "correct_answer", "explanation"]
88
    }`
89
)
90

91
var (
92
    // BatchQuestionsSchema is a batch wrapper around SingleQuestionSchema.
93
    BatchQuestionsSchema = fmt.Sprintf(`{"type":"array","items":%s}`, SingleQuestionSchema)
94

95
    // BatchReadingComprehensionSchema is a batch wrapper around SingleReadingComprehensionSchema.
96
    BatchReadingComprehensionSchema = fmt.Sprintf(`{"type":"array","items":%s}`, SingleReadingComprehensionSchema)
97

98
    // BatchVocabularyQuestionSchema is a batch wrapper around SingleVocabularyQuestionSchema.
99
    BatchVocabularyQuestionSchema = fmt.Sprintf(`{"type":"array","items":%s}`, SingleVocabularyQuestionSchema)
100
)
101

102
// UserAIConfig holds per-user AI configuration
103
type UserAIConfig struct {
104
    Provider string
105
    Model    string
106
    APIKey   string
107
    Username string // For logging purposes
108
}
109

110
// AIServiceInterface defines the interface for AI-powered question generation
111
type AIServiceInterface interface {
112
    GenerateQuestion(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest) (*models.Question, error)
113
    GenerateQuestions(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest) ([]*models.Question, error)
114
    GenerateQuestionsStream(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest, progress chan<- *models.Question, variety *VarietyElements) error
115
    GenerateChatResponse(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIChatRequest) (string, error)
116
    GenerateChatResponseStream(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIChatRequest, chunks chan<- string) error
117
    GenerateStorySection(ctx context.Context, userConfig *models.UserAIConfig, req *models.StoryGenerationRequest) (string, error)
118
    GenerateStoryQuestions(ctx context.Context, userConfig *models.UserAIConfig, req *models.StoryQuestionsRequest) ([]*models.StorySectionQuestionData, error)
119
    TestConnection(ctx context.Context, provider, model, apiKey string) error
120
    GetConcurrencyStats() ConcurrencyStats
121
    GetQuestionBatchSize(provider string) int
122
    VarietyService() *VarietyService
123

124
    // TemplateManager exposes template rendering and example loading for prompts
125
    TemplateManager() *AITemplateManager
126

127
    // SupportsGrammarField reports whether the provider supports the grammar field
128
    SupportsGrammarField(provider string) bool
129

130
    // CallWithPrompt sends a raw prompt (and optional grammar) to the provider and returns the response
131
    CallWithPrompt(ctx context.Context, userConfig *models.UserAIConfig, prompt, grammar string) (string, error)
132
    Shutdown(ctx context.Context) error
133
}
134

135
// ConcurrencyStats provides metrics about AI request concurrency
136
type ConcurrencyStats struct {
137
    ActiveRequests  int            `json:"active_requests"`
138
    MaxConcurrent   int            `json:"max_concurrent"`
139
    QueuedRequests  int            `json:"queued_requests"`
140
    TotalRequests   int64          `json:"total_requests"`
141
    UserActiveCount map[string]int `json:"user_active_count"`
142
    MaxPerUser      int            `json:"max_per_user"`
143
}
144

145
// AIService provides AI-powered question generation using OpenAI-compatible APIs
146
type AIService struct {
147
    httpClient *http.Client
148
    debug      bool
149
    cfg        *config.Config
150

151
    // Template management
152
    templateManager *AITemplateManager
153

154
    // Variety service for question diversity
155
    varietyService *VarietyService
156

157
    // Usage stats service for tracking token usage
158
    usageStatsSvc UsageStatsServiceInterface
159

160
    // Concurrency control
161
    globalSemaphore chan struct{} // Limits total concurrent requests
162
    maxConcurrent   int           // Maximum concurrent requests globally
163
    maxPerUser      int           // Maximum concurrent requests per user
164

165
    // Per-user concurrency tracking
166
    userRequestCount map[string]int // Username -> active request count
167
    concurrencyMu    sync.RWMutex   // Protects user maps
168

169
    // Metrics
170
    totalRequests  int64        // Total requests processed
171
    activeRequests int          // Current active requests
172
    statsMu        sync.RWMutex // Protects stats
173

174
    // Observability
175
    logger *observability.Logger
176

177
    // Shutdown control
178
    shutdownCtx context.Context
179
    shutdownMu  sync.RWMutex
180
}
181

182
// Schema validation counters
183
var (
184
    SchemaValidationFailures       = make(map[models.QuestionType]int)
185
    SchemaValidationFailureDetails = make(map[models.QuestionType][]string) // NEW: error details
186
    SchemaValidationMu             sync.Mutex
187
)
188

189
// extractItemsSchema extracts the items schema from a batch schema
190
2021x
func extractItemsSchema(batchSchema string) (result0 string, err error) {
191
2021x
    var schemaMap map[string]interface{}
192
2021x
    if err = json.Unmarshal([]byte(batchSchema), &schemaMap); err != nil {
193
        return "", err
194
    }
195
    // For batch schemas, extract the items schema
196
2021x
    if items, ok := schemaMap["items"]; ok {
197
2021x
        var itemsBytes []byte
198
2021x
        itemsBytes, err = json.Marshal(items)
199
2021x
        if err != nil {
200
            return "", err
201
        }
202
2021x
        return string(itemsBytes), nil
203
    }
204
    return "", contextutils.ErrorWithContextf("no items found in batch schema")
205
}
206

207
// ValidateQuestionSchema validates a question against the appropriate schema
208
2013x
func (s *AIService) ValidateQuestionSchema(ctx context.Context, qType models.QuestionType, question interface{}) (result0 bool, err error) {
209
2013x
    _, span := observability.TraceAIFunction(ctx, "validate_question_schema",
210
2013x
        observability.AttributeQuestionType(qType),
211
2013x
    )
212
2013x
    defer observability.FinishSpan(span, &err)
213
2013x

214
2013x
    // Validate input parameters
215
2013x
    if question == nil {
216
        span.SetAttributes(attribute.String("validation.result", "nil_question"))
217
        return false, contextutils.ErrorWithContextf("question cannot be nil")
218
    }
219

220
2013x
    var schema string
221
2013x
    switch qType {
222
2011x
    case models.Vocabulary:
223
2011x
        schema = BatchVocabularyQuestionSchema
224
1x
    case models.ReadingComprehension:
225
1x
        schema = BatchReadingComprehensionSchema
226
    case models.FillInBlank, models.QuestionAnswer:
227
        schema = BatchQuestionsSchema
228
    default:
229
        span.SetAttributes(attribute.String("validation.result", "unknown_type"))
230
        return false, contextutils.ErrorWithContextf("unknown question type: %v", qType)
231
    }
232

233
    // Extract the items schema for validation
234
2013x
    itemSchema, err := extractItemsSchema(schema)
235
2013x
    if err != nil {
236
        span.SetAttributes(attribute.String("validation.result", "schema_extract_error"), attribute.String("validation.error", err.Error()))
237
        return false, contextutils.WrapErrorf(err, "failed to extract schema for question type %v", qType)
238
    }
239

240
    // Marshal the question to JSON
241
    // If question is a *models.Question, validate only Content
242
2013x
    toValidate := question
243
2013x
    if q, ok := question.(*models.Question); ok {
244
2013x
        if q == nil {
245
            span.SetAttributes(attribute.String("validation.result", "nil_question_model"))
246
            return false, contextutils.ErrorWithContextf("question model is nil")
247
        }
248
2013x
        toValidate = q.Content
249
    }
250

251
2013x
    questionBytes, err := json.Marshal(toValidate)
252
2013x
    if err != nil {
253
        span.SetAttributes(attribute.String("validation.result", "marshal_error"), attribute.String("validation.error", err.Error()))
254
        return false, contextutils.WrapErrorf(err, "failed to marshal question for validation")
255
    }
256

257
    // Validate
258
2013x
    result, err := gojsonschema.Validate(
259
2013x
        gojsonschema.NewStringLoader(itemSchema),
260
2013x
        gojsonschema.NewBytesLoader(questionBytes),
261
2013x
    )
262
2013x
    if err != nil {
263
        span.SetAttributes(attribute.String("validation.result", "validate_error"), attribute.String("validation.error", err.Error()))
264
        return false, contextutils.WrapErrorf(err, "schema validation failed for question type %v", qType)
265
    }
266

267
2013x
    if !result.Valid() {
268
        errs := result.Errors()
269
        var errorMessages []string
270
        for _, e := range errs {
271
            errorMessages = append(errorMessages, e.String())
272
        }
273
        span.SetAttributes(attribute.String("validation.result", "invalid"))
274
        return false, contextutils.ErrorWithContextf("question failed schema validation: %s", strings.Join(errorMessages, "; "))
275
    }
276

277
2013x
    span.SetAttributes(attribute.String("validation.result", "valid"))
278
2013x
    return true, nil
279
}
280

281
// NewAIService creates a new AI service instance
282
60x
func NewAIService(cfg *config.Config, logger *observability.Logger, usageStatsSvc UsageStatsServiceInterface) *AIService {
283
60x
    // Validate required dependencies
284
60x
    if usageStatsSvc == nil {
285
        panic("usageStatsSvc is required for AI service")
286
    }
287

288
    // Create template manager
289
60x
    templateManager, err := NewAITemplateManager()
290
60x
    if err != nil {
291
        logger.Error(context.Background(), "Failed to create template manager", err, map[string]interface{}{})
292
        panic(err) // Use panic for fatal errors in initialization
293
    }
294

295
    // Create variety service
296
60x
    varietyService := NewVarietyServiceWithLogger(cfg, logger)
297
60x

298
60x
    // Create instrumented HTTP client with reasonable timeouts and explicit span options
299
60x
    // Use a timeout slightly less than AIRequestTimeout to allow context cancellation
300
60x
    httpClient := &http.Client{
301
60x
        Timeout: config.AIRequestTimeout - 5*time.Second, // Slightly less than AIRequestTimeout
302
60x
        Transport: otelhttp.NewTransport(http.DefaultTransport,
303
60x
            otelhttp.WithSpanOptions(trace.WithSpanKind(trace.SpanKindClient)),
304
60x
        ),
305
60x
    }
306
60x

307
60x
    // Get concurrency limits from config
308
60x
    maxConcurrent := cfg.Server.MaxAIConcurrent
309
60x
    maxPerUser := cfg.Server.MaxAIPerUser
310
60x

311
60x
    // Create global semaphore for limiting concurrent requests
312
60x
    globalSemaphore := make(chan struct{}, maxConcurrent)
313
60x

314
60x
    service := &AIService{
315
60x
        httpClient:       httpClient,
316
60x
        debug:            cfg.Server.Debug,
317
60x
        cfg:              cfg,
318
60x
        templateManager:  templateManager,
319
60x
        varietyService:   varietyService,
320
60x
        usageStatsSvc:    usageStatsSvc,
321
60x
        globalSemaphore:  globalSemaphore,
322
60x
        maxConcurrent:    maxConcurrent,
323
60x
        maxPerUser:       maxPerUser,
324
60x
        userRequestCount: make(map[string]int),
325
60x
        shutdownCtx:      context.Background(),
326
60x
        logger:           logger,
327
60x
    }
328
60x

329
60x
    return service
330
}
331

332
// Shutdown gracefully shuts down the AI service and cleans up resources
333
6x
func (s *AIService) Shutdown(ctx context.Context) error {
334
6x
    s.shutdownMu.Lock()
335
6x
    defer s.shutdownMu.Unlock()
336
6x

337
6x
    // Create a new shutdown context
338
6x
    shutdownCtx, cancel := context.WithCancel(ctx)
339
6x
    s.shutdownCtx = shutdownCtx
340
6x
    defer cancel()
341
6x

342
6x
    // Wait for all active requests to complete with timeout
343
6x
    timeout := config.AIShutdownTimeout
344
6x
    if deadline, ok := ctx.Deadline(); ok {
345
1x
        timeout = time.Until(deadline)
346
1x
    }
347

348
    // Wait for active requests to complete
349
6x
    ticker := time.NewTicker(config.AIShutdownPollInterval)
350
6x
    defer ticker.Stop()
351
6x

352
6x
    for i := 0; i < int(timeout/config.AIShutdownPollInterval); i++ {
353
6x
        s.statsMu.RLock()
354
6x
        active := s.activeRequests
355
6x
        s.statsMu.RUnlock()
356
6x

357
6x
        if active == 0 {
358
6x
            break
359
        }
360

361
        select {
362
        case <-ticker.C:
363
            continue
364
        case <-ctx.Done():
365
            return ctx.Err()
366
        }
367
    }
368

369
    // Close the HTTP client
370
6x
    if s.httpClient != nil {
371
6x
        s.httpClient.CloseIdleConnections()
372
6x
    }
373

374
    // Clean up user request counts
375
6x
    s.concurrencyMu.Lock()
376
6x
    s.userRequestCount = make(map[string]int)
377
6x
    s.concurrencyMu.Unlock()
378
6x

379
6x
    s.logger.Info(ctx, "AI Service shutdown completed")
380
6x
    return nil
381
}
382

383
// isShutdown checks if the service is shutting down
384
15x
func (s *AIService) isShutdown() bool {
385
15x
    s.shutdownMu.RLock()
386
15x
    defer s.shutdownMu.RUnlock()
387
15x
    select {
388
3x
    case <-s.shutdownCtx.Done():
389
3x
        return true
390
9x
    default:
391
9x
        return false
392
    }
393
}
394

395
// OpenAIRequest represents a request to the OpenAI-compatible API
396
type OpenAIRequest struct {
397
    Model       string    `json:"model"`
398
    Messages    []Message `json:"messages"`
399
    Temperature float64   `json:"temperature"`
400
    MaxTokens   int       `json:"max_tokens"`
401
    Grammar     string    `json:"grammar,omitempty"`
402
    Stream      bool      `json:"stream,omitempty"`
403
}
404

405
// Message represents a chat message in the API request
406
type Message struct {
407
    Role    string `json:"role"`
408
    Content string `json:"content"`
409
}
410

411
// OpenAIResponse represents a response from the OpenAI-compatible API
412
type OpenAIResponse struct {
413
    Choices []Choice  `json:"choices"`
414
    Error   *APIError `json:"error,omitempty"`
415
    Usage   *Usage    `json:"usage,omitempty"`
416
}
417

418
// Choice represents a choice in the API response
419
type Choice struct {
420
    Message Message `json:"message"`
421
}
422

423
// APIError represents an error response from the API
424
type APIError struct {
425
    Message string `json:"message"`
426
    Type    string `json:"type"`
427
}
428

429
// Usage represents token usage information from OpenAI API
430
type Usage struct {
431
    PromptTokens     int `json:"prompt_tokens"`
432
    CompletionTokens int `json:"completion_tokens"`
433
    TotalTokens      int `json:"total_tokens"`
434
}
435

436
// OpenAIStreamResponse represents a streaming response chunk from the OpenAI-compatible API
437
type OpenAIStreamResponse struct {
438
    Choices []StreamChoice `json:"choices"`
439
    Error   *APIError      `json:"error,omitempty"`
440
    Usage   *Usage         `json:"usage,omitempty"`
441
}
442

443
// StreamChoice represents a choice in the streaming API response
444
type StreamChoice struct {
445
    Delta        StreamDelta `json:"delta"`
446
    FinishReason *string     `json:"finish_reason"`
447
}
448

449
// StreamDelta represents the delta content in a streaming response
450
type StreamDelta struct {
451
    Content string `json:"content"`
452
}
453

454
// getGrammarSchema returns the appropriate JSON schema for the given question type
455
30x
func getGrammarSchema(questionType models.QuestionType) string {
456
30x
    // Always return the batch schema for each type
457
30x
    switch questionType {
458
3x
    case models.ReadingComprehension:
459
3x
        return BatchReadingComprehensionSchema
460
21x
    case models.Vocabulary:
461
21x
        return BatchVocabularyQuestionSchema
462
3x
    case models.FillInBlank:
463
3x
        return BatchQuestionsSchema
464
3x
    case models.QuestionAnswer:
465
3x
        return BatchQuestionsSchema
466
    }
467
    // Fallback for unknown types
468
    return BatchQuestionsSchema
469
}
470

471
// GetFixSchema returns the single-item JSON schema for ai-fix or an error if unsupported.
472
func GetFixSchema(questionType models.QuestionType) (string, error) {
473
    switch questionType {
474
    case models.ReadingComprehension:
475
        return SingleReadingComprehensionSchema, nil
476
    case models.Vocabulary:
477
        return SingleVocabularyQuestionSchema, nil
478
    case models.FillInBlank:
479
        return SingleFillBlankSchema, nil
480
    case models.QuestionAnswer:
481
        return SingleQuestionSchema, nil
482
    default:
483
        return "", contextutils.WrapErrorf(contextutils.ErrAIConfigInvalid, "no schema for question type: %v", questionType)
484
    }
485
}
486

487
// addJSONStructureGuidance appends JSON structure requirements to prompts for providers that don't support grammar
488
7x
func (s *AIService) addJSONStructureGuidance(prompt string, questionType models.QuestionType) string {
489
7x
    // Get the schema for this question type
490
7x
    schema := getGrammarSchema(questionType)
491
7x

492
7x
    data := AITemplateData{
493
7x
        SchemaForPrompt: schema,
494
7x
    }
495
7x

496
7x
    guidance, err := s.templateManager.RenderTemplate(JSONStructureGuidanceTemplate, data)
497
7x
    if err != nil {
498
        s.logger.Error(context.Background(), "Failed to render JSON structure guidance template", err, map[string]interface{}{})
499
        panic(err)
500
    }
501

502
7x
    return prompt + guidance
503
}
504

505
// GenerateQuestion generates a single question using AI
506
3x
func (s *AIService) GenerateQuestion(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest) (result0 *models.Question, err error) {
507
3x
    ctx, span := observability.TraceAIFunction(ctx, "generate_question",
508
3x
        attribute.String("user.username", userConfig.Username),
509
3x
        attribute.String("ai.provider", userConfig.Provider),
510
3x
        attribute.String("ai.model", userConfig.Model),
511
3x
        observability.AttributeQuestionType(string(req.QuestionType)),
512
3x
    )
513
3x
    defer observability.FinishSpan(span, &err)
514
3x
    // Check if the provider supports grammar field
515
3x
    supportsGrammar := s.supportsGrammarField(userConfig.Provider)
516
3x

517
3x
    var prompt string
518
3x
    var grammar string
519
3x

520
3x
    if supportsGrammar {
521
3x
        // Use batch prompt with count=1 for single question
522
3x
        prompt = s.buildBatchQuestionPrompt(ctx, req, nil)
523
3x
        grammar = getGrammarSchema(req.QuestionType)
524
3x
    } else {
525
        // Use batch prompt with JSON structure guidance embedded
526
        prompt = s.buildBatchQuestionPromptWithJSONStructure(ctx, req, nil)
527
        grammar = "" // No grammar field for providers that don't support it
528
    }
529

530
3x
    response, err := s.callOpenAI(ctx, userConfig, prompt, grammar)
531
3x
    if err != nil {
532
3x
        return nil, err
533
3x
    }
534

535
    question, err := s.parseQuestionResponse(ctx, response, req.Language, req.Level, req.QuestionType, userConfig.Provider)
536
    if err != nil {
537
        return nil, err
538
    }
539

540
    return question, nil
541
}
542

543
// GenerateQuestions generates multiple questions in a single batch request
544
1x
func (s *AIService) GenerateQuestions(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest) (result0 []*models.Question, err error) {
545
1x
    ctx, span := observability.TraceAIFunction(ctx, "generate_questions",
546
1x
        attribute.String("user.username", userConfig.Username),
547
1x
        attribute.String("ai.provider", userConfig.Provider),
548
1x
        attribute.String("ai.model", userConfig.Model),
549
1x
        observability.AttributeQuestionType(string(req.QuestionType)),
550
1x
        observability.AttributeLimit(req.Count),
551
1x
    )
552
1x
    defer observability.FinishSpan(span, &err)
553
1x
    // Check if the provider supports grammar field
554
1x
    supportsGrammar := s.supportsGrammarField(userConfig.Provider)
555
1x

556
1x
    var prompt string
557
1x
    var grammar string
558
1x

559
1x
    if supportsGrammar {
560
1x
        // Use regular prompt with grammar field
561
1x
        prompt = s.buildBatchQuestionPrompt(ctx, req, nil)
562
1x
        grammar = getGrammarSchema(req.QuestionType)
563
1x
    } else {
564
        // Use prompt with JSON structure guidance embedded
565
        prompt = s.buildBatchQuestionPromptWithJSONStructure(ctx, req, nil)
566
        grammar = "" // No grammar field for providers that don't support it
567
    }
568

569
1x
    response, err := s.callOpenAI(ctx, userConfig, prompt, grammar)
570
1x
    if err != nil {
571
1x
        return nil, err
572
1x
    }
573

574
    questions, err := s.parseQuestionsResponse(ctx, response, req.Language, req.Level, req.QuestionType, userConfig.Provider)
575
    if err != nil {
576
        return nil, err
577
    }
578

579
    return questions, nil
580
}
581

582
// GenerateQuestionsStream generates questions and streams them via a channel, using the provided variety elements
583
2x
func (s *AIService) GenerateQuestionsStream(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest, progress chan<- *models.Question, variety *VarietyElements) (err error) {
584
2x
    ctx, span := observability.TraceAIFunction(ctx, "generate_questions_stream",
585
2x
        attribute.String("user.username", userConfig.Username),
586
2x
        attribute.String("ai.provider", userConfig.Provider),
587
2x
        attribute.String("ai.model", userConfig.Model),
588
2x
        observability.AttributeQuestionType(string(req.QuestionType)),
589
2x
        observability.AttributeLimit(req.Count),
590
2x
    )
591
2x
    defer observability.FinishSpan(span, &err)
592
2x
    defer close(progress)
593
2x

594
2x
    return s.withConcurrencyControl(ctx, userConfig.Username, func() error {
595
1x
        // Get the batch size for this provider
596
1x
        batchSize := s.getQuestionBatchSize(userConfig.Provider)
597
1x
        // Use batch generation for multiple questions
598
1x
        return s.generateQuestionsInBatchesWithVariety(ctx, userConfig, req, progress, batchSize, variety)
599
1x
    })
600
}
601

602
// generateQuestionsInBatchesWithVariety generates questions in batches for efficiency, using the provided variety elements
603
1x
func (s *AIService) generateQuestionsInBatchesWithVariety(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest, progress chan<- *models.Question, batchSize int, variety *VarietyElements) (err error) {
604
1x
    ctx, span := observability.TraceAIFunction(ctx, "generate_questions_in_batches_with_variety",
605
1x
        attribute.String("ai.provider", userConfig.Provider),
606
1x
        attribute.String("ai.model", userConfig.Model),
607
1x
        observability.AttributeQuestionType(req.QuestionType),
608
1x
        observability.AttributeLanguage(req.Language),
609
1x
        observability.AttributeLevel(req.Level),
610
1x
        attribute.Int("batch_size", batchSize),
611
1x
        attribute.Int("total_count", req.Count),
612
1x
        attribute.Bool("variety.enabled", variety != nil),
613
1x
    )
614
1x
    defer observability.FinishSpan(span, &err)
615
1x
    // Local copy of history to be updated as we generate questions
616
1x
    localHistory := make([]string, len(req.RecentQuestionHistory))
617
1x
    copy(localHistory, req.RecentQuestionHistory)
618
1x

619
1x
    remaining := req.Count
620
1x
    generated := 0
621
1x

622
1x
    for remaining > 0 {
623
1x
        // Check for context cancellation
624
1x
        select {
625
        case <-ctx.Done():
626
            return ctx.Err()
627
1x
        default:
628
        }
629

630
        // Calculate how many questions to generate in this batch
631
1x
        currentBatchSize := min(remaining, batchSize)
632
1x

633
1x
        // Create a batch request
634
1x
        batchReq := &models.AIQuestionGenRequest{
635
1x
            Language:              req.Language,
636
1x
            Level:                 req.Level,
637
1x
            QuestionType:          req.QuestionType,
638
1x
            Count:                 currentBatchSize,
639
1x
            RecentQuestionHistory: localHistory,
640
1x
        }
641
1x

642
1x
        // Generate questions in batch using the provided variety elements
643
1x
        questions, err := s.generateQuestionsWithVariety(ctx, userConfig, batchReq, variety)
644
1x
        if err != nil {
645
1x
            return contextutils.WrapErrorf(err, "failed to generate batch of %d questions for user %s", currentBatchSize, userConfig.Username)
646
1x
        }
647

648
        // Stream the generated questions
649
        for _, question := range questions {
650
            // Add generated question content to history for next iterations
651
            if qContent, ok := question.Content["question"]; ok {
652
                if qStr, ok := qContent.(string); ok {
653
                    localHistory = append(localHistory, qStr)
654
                }
655
            }
656

657
            progress <- question
658
            generated++
659
        }
660

661
        remaining -= len(questions)
662
    }
663

664
    return nil
665
}
666

667
// generateQuestionsWithVariety generates a batch of questions using the provided variety elements
668
1x
func (s *AIService) generateQuestionsWithVariety(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest, variety *VarietyElements) (result0 []*models.Question, err error) {
669
1x
    ctx, span := observability.TraceAIFunction(ctx, "generate_questions_with_variety",
670
1x
        attribute.String("ai.provider", userConfig.Provider),
671
1x
        attribute.String("ai.model", userConfig.Model),
672
1x
        observability.AttributeQuestionType(req.QuestionType),
673
1x
        observability.AttributeLanguage(req.Language),
674
1x
        observability.AttributeLevel(req.Level),
675
1x
        attribute.Int("count", req.Count),
676
1x
        attribute.Bool("variety.enabled", variety != nil),
677
1x
    )
678
1x
    defer func() {
679
1x
        if err != nil {
680
1x
            span.RecordError(err, trace.WithStackTrace(true))
681
1x
            span.SetStatus(codes.Error, err.Error())
682
1x
        }
683
1x
        span.End()
684
    }()
685
    // Check if the provider supports grammar field
686
1x
    supportsGrammar := s.supportsGrammarField(userConfig.Provider)
687
1x

688
1x
    var prompt string
689
1x
    var grammar string
690
1x

691
1x
    if supportsGrammar {
692
        prompt = s.buildBatchQuestionPrompt(ctx, req, variety)
693
        grammar = getGrammarSchema(req.QuestionType)
694
    } else {
695
1x
        prompt = s.buildBatchQuestionPromptWithJSONStructure(ctx, req, variety)
696
1x
        grammar = ""
697
1x
    }
698

699
1x
    response, err := s.callOpenAI(ctx, userConfig, prompt, grammar)
700
1x
    if err != nil {
701
1x
        return nil, err
702
1x
    }
703

704
    questions, err := s.parseQuestionsResponse(ctx, response, req.Language, req.Level, req.QuestionType, userConfig.Provider)
705
    if err != nil {
706
        return nil, err
707
    }
708

709
    return questions, nil
710
}
711

712
// GenerateChatResponse generates a chat response using AI
713
1x
func (s *AIService) GenerateChatResponse(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIChatRequest) (result0 string, err error) {
714
1x
    ctx, span := observability.TraceAIFunction(ctx, "generate_chat_response",
715
1x
        attribute.String("user.username", userConfig.Username),
716
1x
        attribute.String("ai.provider", userConfig.Provider),
717
1x
        attribute.String("ai.model", userConfig.Model),
718
1x
    )
719
1x
    defer observability.FinishSpan(span, &err)
720
1x
    var result string
721
1x
    var resultErr error
722
1x

723
1x
    err = s.withConcurrencyControl(ctx, userConfig.Username, func() error {
724
1x
        prompt := s.buildChatPrompt(req)
725
1x
        // No grammar constraint for open-ended chat
726
1x
        result, resultErr = s.callOpenAI(ctx, userConfig, prompt, "")
727
1x
        return resultErr
728
1x
    })
729
1x
    if err != nil {
730
1x
        return "", err
731
1x
    }
732
    return result, resultErr
733
}
734

735
// GenerateChatResponseStream generates a streaming chat response using AI
736
2x
func (s *AIService) GenerateChatResponseStream(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIChatRequest, chunks chan<- string) (err error) {
737
2x
    ctx, span := observability.TraceAIFunction(ctx, "generate_chat_response_stream",
738
2x
        attribute.String("user.username", userConfig.Username),
739
2x
        attribute.String("ai.provider", userConfig.Provider),
740
2x
        attribute.String("ai.model", userConfig.Model),
741
2x
    )
742
2x
    defer observability.FinishSpan(span, &err)
743
2x
    // Don't close the channel here - let the caller handle it to avoid race conditions
744
2x

745
2x
    return s.withConcurrencyControl(ctx, userConfig.Username, func() error {
746
1x
        prompt := s.buildChatPrompt(req)
747
1x
        // No grammar constraint for open-ended chat
748
1x
        return s.callOpenAIStream(ctx, userConfig, prompt, "", chunks)
749
1x
    })
750
}
751

752
// GenerateStorySection generates a story section using AI
753
func (s *AIService) GenerateStorySection(ctx context.Context, userConfig *models.UserAIConfig, req *models.StoryGenerationRequest) (result string, err error) {
754
    ctx, span := observability.TraceAIFunction(ctx, "generate_story_section",
755
        attribute.String("user.username", userConfig.Username),
756
        attribute.String("ai.provider", userConfig.Provider),
757
        attribute.String("ai.model", userConfig.Model),
758
        attribute.String("story.title", req.Title),
759
        attribute.String("story.language", req.Language),
760
        attribute.String("story.level", req.Level),
761
        attribute.Bool("story.is_first_section", req.IsFirstSection),
762
    )
763
    defer observability.FinishSpan(span, &err)
764

765
    var storyResult string
766
    var storyErr error
767

768
    err = s.withConcurrencyControl(ctx, userConfig.Username, func() error {
769
        prompt := s.buildStorySectionPrompt(req)
770
        storyResult, storyErr = s.callOpenAIWithRetry(ctx, userConfig, prompt, "")
771
        return storyErr
772
    })
773
    if err != nil {
774
        return "", err
775
    }
776
    return storyResult, storyErr
777
}
778

779
// GenerateStoryQuestions generates comprehension questions for a story section
780
func (s *AIService) GenerateStoryQuestions(ctx context.Context, userConfig *models.UserAIConfig, req *models.StoryQuestionsRequest) (result []*models.StorySectionQuestionData, err error) {
781
    ctx, span := observability.TraceAIFunction(ctx, "generate_story_questions",
782
        attribute.String("user.username", userConfig.Username),
783
        attribute.String("ai.provider", userConfig.Provider),
784
        attribute.String("ai.model", userConfig.Model),
785
        attribute.String("story.language", req.Language),
786
        attribute.String("story.level", req.Level),
787
        attribute.Int("questions.count", req.QuestionCount),
788
    )
789
    defer observability.FinishSpan(span, &err)
790

791
    var questionsResult []*models.StorySectionQuestionData
792
    var questionsErr error
793

794
    err = s.withConcurrencyControl(ctx, userConfig.Username, func() error {
795
        prompt := s.buildStoryQuestionsPrompt(req)
796
        response, responseErr := s.callOpenAI(ctx, userConfig, prompt, "")
797
        if responseErr != nil {
798
            return responseErr
799
        }
800

801
        // Parse the JSON response into question data
802
        questionsResult, questionsErr = s.parseStoryQuestionsResponse(response)
803
        if questionsErr != nil {
804
            return contextutils.WrapErrorf(questionsErr, "failed to parse story questions response")
805
        }
806

807
        return nil
808
    })
809
    if err != nil {
810
        return nil, err
811
    }
812
    return questionsResult, questionsErr
813
}
814

815
// stringPtrToString converts a *string to string, returning empty string if nil
816
14x
func stringPtrToString(ptr *string) string {
817
14x
    if ptr == nil {
818
7x
        return ""
819
7x
    }
820
7x
    return *ptr
821
}
822

823
// buildStorySectionPrompt builds the prompt for story section generation
824
2x
func (s *AIService) buildStorySectionPrompt(req *models.StoryGenerationRequest) string {
825
2x
    // Create template data from the request
826
2x
    templateData := AITemplateData{
827
2x
        Language:           req.Language,
828
2x
        Level:              req.Level,
829
2x
        Title:              req.Title,
830
2x
        Subject:            stringPtrToString(req.Subject),
831
2x
        AuthorStyle:        stringPtrToString(req.AuthorStyle),
832
2x
        TimePeriod:         stringPtrToString(req.TimePeriod),
833
2x
        Genre:              stringPtrToString(req.Genre),
834
2x
        Tone:               stringPtrToString(req.Tone),
835
2x
        CharacterNames:     stringPtrToString(req.CharacterNames),
836
2x
        CustomInstructions: stringPtrToString(req.CustomInstructions),
837
2x
        TargetWords:        req.TargetWords,
838
2x
        TargetSentences:    req.TargetSentences,
839
2x
        IsFirstSection:     req.IsFirstSection,
840
2x
        PreviousSections:   req.PreviousSections,
841
2x
    }
842
2x

843
2x
    template, err := s.templateManager.RenderTemplate("story_section_prompt.tmpl", templateData)
844
2x
    if err != nil {
845
        // No fallback - error out if template not found
846
        panic(contextutils.WrapErrorf(err, "failed to render story section template"))
847
    }
848

849
2x
    return template
850
}
851

852
// buildStoryQuestionsPrompt builds the prompt for story questions generation
853
1x
func (s *AIService) buildStoryQuestionsPrompt(req *models.StoryQuestionsRequest) string {
854
1x
    // Create template data from the request
855
1x
    templateData := AITemplateData{
856
1x
        Language:    req.Language,
857
1x
        Level:       req.Level,
858
1x
        Count:       req.QuestionCount,
859
1x
        SectionText: req.SectionText,
860
1x
    }
861
1x

862
1x
    template, err := s.templateManager.RenderTemplate("story_questions_prompt.tmpl", templateData)
863
1x
    if err != nil {
864
        // No fallback - error out if template not found
865
        panic(contextutils.WrapErrorf(err, "failed to render story questions template"))
866
    }
867

868
1x
    return template
869
}
870

871
// parseStoryQuestionsResponse parses the AI response into question data
872
func (s *AIService) parseStoryQuestionsResponse(response string) ([]*models.StorySectionQuestionData, error) {
873
    // Clean the response (remove markdown code blocks if present)
874
    response = strings.TrimSpace(response)
875
    if strings.HasPrefix(response, "```json") {
876
        response = strings.TrimPrefix(response, "```json")
877
        response = strings.TrimSuffix(response, "```")
878
        response = strings.TrimSpace(response)
879
    }
880

881
    var questions []*models.StorySectionQuestionData
882
    if err := json.Unmarshal([]byte(response), &questions); err != nil {
883
        return nil, contextutils.WrapErrorf(err, "failed to unmarshal questions JSON")
884
    }
885

886
    // Validate the questions
887
    for i, q := range questions {
888
        if q.QuestionText == "" {
889
            return nil, contextutils.ErrorWithContextf("question %d: missing question text", i)
890
        }
891
        if len(q.Options) != 4 {
892
            return nil, contextutils.ErrorWithContextf("question %d: must have exactly 4 options, got %d", i, len(q.Options))
893
        }
894
        if q.CorrectAnswerIndex < 0 || q.CorrectAnswerIndex >= 4 {
895
            return nil, contextutils.ErrorWithContextf("question %d: correct_answer_index must be 0-3, got %d", i, q.CorrectAnswerIndex)
896
        }
897
    }
898

899
    return questions, nil
900
}
901

902
// TestConnection tests the connection to the AI service
903
3x
func (s *AIService) TestConnection(ctx context.Context, provider, model, apiKey string) (err error) {
904
3x
    _, span := observability.TraceAIFunction(ctx, "test_connection",
905
3x
        attribute.String("ai.provider", provider),
906
3x
        attribute.String("ai.model", model),
907
3x
    )
908
3x
    defer observability.FinishSpan(span, &err)
909
3x

910
3x
    // Validate input parameters
911
3x
    if provider == "" {
912
        span.SetAttributes(attribute.String("test.result", "empty_provider"))
913
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "provider is required for testing connection")
914
    }
915

916
3x
    if model == "" {
917
        span.SetAttributes(attribute.String("test.result", "empty_model"))
918
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "model is required for testing connection")
919
    }
920

921
3x
    s.logger.Debug(ctx, "TestConnection called", map[string]interface{}{
922
3x
        "provider": provider,
923
3x
        "model":    model,
924
3x
        "apiKey":   contextutils.MaskAPIKey(apiKey),
925
3x
    })
926
3x

927
3x
    // Require API key for all providers that are not Ollama
928
3x
    if provider != "ollama" && apiKey == "" {
929
1x
        span.SetAttributes(attribute.String("test.result", "missing_api_key"), attribute.String("provider", provider))
930
1x
        return contextutils.WrapErrorf(contextutils.ErrAIConfigInvalid, "API key is required for testing connection with provider '%s'", provider)
931
1x
    }
932

933
    // Create a simple test configuration
934
2x
    userConfig := &models.UserAIConfig{
935
2x
        Provider: provider,
936
2x
        Model:    model,
937
2x
        APIKey:   apiKey,
938
2x
        Username: "test-user",
939
2x
    }
940
2x

941
2x
    s.logger.Debug(ctx, "Created userConfig", map[string]interface{}{
942
2x
        "provider": userConfig.Provider,
943
2x
        "model":    userConfig.Model,
944
2x
    })
945
2x

946
2x
    // Create a simple test request
947
2x
    testPrompt := "Respond with exactly the word 'SUCCESS' and nothing else."
948
2x

949
2x
    // Create a timeout context for the test
950
2x
    testCtx, cancel := context.WithTimeout(ctx, config.AIRequestTimeout)
951
2x
    defer cancel()
952
2x

953
2x
    // Test the actual AI service call
954
2x
    response, err := s.callOpenAI(testCtx, userConfig, testPrompt, "")
955
2x
    if err != nil {
956
2x
        span.SetAttributes(attribute.String("test.result", "call_failed"), attribute.String("error", err.Error()))
957
2x
        return contextutils.WrapErrorf(err, "connection test failed for provider '%s' with model '%s'", provider, model)
958
2x
    }
959

960
    // Check if we got a reasonable response
961
    if response == "" {
962
        span.SetAttributes(attribute.String("test.result", "empty_response"))
963
        return contextutils.WrapError(contextutils.ErrAIResponseInvalid, "connection test failed: received empty response from AI service")
964
    }
965

966
    // Validate that the response contains something meaningful
967
    if len(response) < 3 {
968
        span.SetAttributes(attribute.String("test.result", "response_too_short"), attribute.Int("response_length", len(response)))
969
        return contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "connection test failed: response too short (%d characters)", len(response))
970
    }
971

972
    // The response should contain something meaningful
973
    s.logger.Info(ctx, "TestConnection successful", map[string]interface{}{
974
        "provider":        provider,
975
        "response_length": len(response),
976
    })
977
    span.SetAttributes(attribute.String("test.result", "success"), attribute.Int("response_length", len(response)))
978
    return nil
979
}
980

981
// buildBatchQuestionPromptWithJSONStructure now takes variety elements
982
3x
func (s *AIService) buildBatchQuestionPromptWithJSONStructure(ctx context.Context, req *models.AIQuestionGenRequest, variety *VarietyElements) string {
983
3x
    prompt := s.buildBatchQuestionPrompt(ctx, req, variety)
984
3x
    return s.addJSONStructureGuidance(prompt, req.QuestionType)
985
3x
}
986

987
// buildBatchQuestionPrompt now takes variety elements
988
11x
func (s *AIService) buildBatchQuestionPrompt(ctx context.Context, req *models.AIQuestionGenRequest, variety *VarietyElements) string {
989
11x
    _, span := observability.TraceAIFunction(ctx, "build_batch_question_prompt",
990
11x
        observability.AttributeQuestionType(req.QuestionType),
991
11x
        observability.AttributeLanguage(req.Language),
992
11x
        observability.AttributeLevel(req.Level),
993
11x
        attribute.Int("count", req.Count),
994
11x
        attribute.Bool("variety.enabled", variety != nil),
995
11x
    )
996
11x
    defer span.End()
997
11x
    tmplData := AITemplateData{
998
11x
        SchemaForPrompt:       getGrammarSchema(req.QuestionType),
999
11x
        Language:              req.Language,
1000
11x
        Level:                 req.Level,
1001
11x
        QuestionType:          string(req.QuestionType),
1002
11x
        Count:                 req.Count,
1003
11x
        RecentQuestionHistory: req.RecentQuestionHistory,
1004
11x
    }
1005
11x
    if variety != nil {
1006
4x
        tmplData.TopicCategory = variety.TopicCategory
1007
4x
        tmplData.GrammarFocus = variety.GrammarFocus
1008
4x
        tmplData.VocabularyDomain = variety.VocabularyDomain
1009
4x
        tmplData.Scenario = variety.Scenario
1010
4x
        tmplData.StyleModifier = variety.StyleModifier
1011
4x
        tmplData.DifficultyModifier = variety.DifficultyModifier
1012
4x
        tmplData.TimeContext = variety.TimeContext
1013
4x
    }
1014

1015
    // Priority data is handled by the worker, not passed to AI service
1016

1017
    // Load example for this question type
1018
11x
    if exampleContent, err := s.templateManager.LoadExample(string(req.QuestionType)); err == nil {
1019
11x
        tmplData.ExampleContent = exampleContent
1020
11x
    }
1021

1022
11x
    prompt, err := s.templateManager.RenderTemplate(BatchQuestionPromptTemplate, tmplData)
1023
11x
    if err != nil {
1024
        s.logger.Error(ctx, "Failed to render batch question prompt template", err, map[string]interface{}{})
1025
        panic(err) // Use panic for fatal errors in template rendering
1026
    }
1027

1028
11x
    return prompt
1029
}
1030

1031
13x
func (s *AIService) buildChatPrompt(req *models.AIChatRequest) string {
1032
13x
    // Convert conversation history to template format
1033
13x
    var conversationHistory []ChatMessage
1034
13x
    for _, msg := range req.ConversationHistory {
1035
        conversationHistory = append(conversationHistory, ChatMessage{
1036
            Role:    string(msg.Role),
1037
            Content: msg.Content,
1038
        })
1039
    }
1040

1041
13x
    data := AITemplateData{
1042
13x
        Language:            req.Language,
1043
13x
        Level:               req.Level,
1044
13x
        QuestionType:        string(req.QuestionType),
1045
13x
        Passage:             req.Passage,
1046
13x
        Question:            req.Question,
1047
13x
        Options:             req.Options,
1048
13x
        IsCorrect:           req.IsCorrect,
1049
13x
        ConversationHistory: conversationHistory,
1050
13x
        UserMessage:         req.UserMessage,
1051
13x
    }
1052
13x

1053
13x
    prompt, err := s.templateManager.RenderTemplate(ChatPromptTemplate, data)
1054
13x
    if err != nil {
1055
        s.logger.Error(context.Background(), "Failed to render chat prompt template", err, map[string]interface{}{})
1056
        panic(err) // Use panic for fatal errors in template rendering
1057
    }
1058

1059
13x
    return prompt
1060
}
1061

1062
// getMaxTokensForModel looks up the max_tokens for a specific provider and model
1063
9x
func (s *AIService) getMaxTokensForModel(provider, model string) int {
1064
9x
    // Look up the model in the provider configuration
1065
9x
    if s.cfg.Providers != nil {
1066
9x
        for _, providerConfig := range s.cfg.Providers {
1067
10x
            if providerConfig.Code == provider {
1068
5x
                for _, modelConfig := range providerConfig.Models {
1069
                    if modelConfig.Code == model {
1070
                        if modelConfig.MaxTokens > 0 {
1071
                            return modelConfig.MaxTokens
1072
                        }
1073
                        break
1074
                    }
1075
                }
1076
5x
                break
1077
            }
1078
        }
1079
    }
1080

1081
    // Default fallback
1082
9x
    return 4000
1083
}
1084

1085
// callOpenAI makes a request to the OpenAI-compatible API
1086
8x
func (s *AIService) callOpenAI(ctx context.Context, userConfig *models.UserAIConfig, prompt, grammar string) (result0 string, err error) {
1087
8x
    if userConfig == nil {
1088
        return "", contextutils.WrapError(contextutils.ErrAIConfigInvalid, "userConfig is required")
1089
    }
1090
8x
    ctx, span := observability.TraceAIFunction(ctx, "call_openai",
1091
8x
        attribute.String("ai.provider", userConfig.Provider),
1092
8x
        attribute.String("ai.model", userConfig.Model),
1093
8x
        attribute.String("ai.username", userConfig.Username),
1094
8x
        attribute.Int("prompt.length", len(prompt)),
1095
8x
        attribute.Bool("grammar.enabled", grammar != ""),
1096
8x
    )
1097
8x
    defer func() {
1098
8x
        if err != nil {
1099
8x
            span.RecordError(err, trace.WithStackTrace(true))
1100
8x
            span.SetStatus(codes.Error, err.Error())
1101
8x
        }
1102
8x
        span.End()
1103
    }()
1104

1105
    // Validate input parameters
1106
8x
    if userConfig.Provider == "" {
1107
        span.SetAttributes(attribute.String("call.result", "empty_provider"))
1108
        return "", contextutils.WrapError(contextutils.ErrAIConfigInvalid, "provider is required")
1109
    }
1110

1111
8x
    if userConfig.Model == "" {
1112
        span.SetAttributes(attribute.String("call.result", "empty_model"))
1113
        return "", contextutils.WrapError(contextutils.ErrAIConfigInvalid, "model is required")
1114
    }
1115

1116
8x
    if prompt == "" {
1117
        span.SetAttributes(attribute.String("call.result", "empty_prompt"))
1118
        return "", contextutils.WrapError(contextutils.ErrAIConfigInvalid, "prompt cannot be empty")
1119
    }
1120

1121
8x
    apiURL := ""
1122
8x
    model := userConfig.Model
1123
8x
    apiKey := userConfig.APIKey
1124
8x

1125
8x
    // Look up the default URL from provider config
1126
8x
    if s.cfg.Providers != nil {
1127
8x
        for _, providerConfig := range s.cfg.Providers {
1128
8x
            if providerConfig.Code == userConfig.Provider && providerConfig.URL != "" {
1129
4x
                apiURL = providerConfig.URL
1130
4x
                break
1131
            }
1132
        }
1133
    }
1134

1135
8x
    if apiURL == "" {
1136
4x
        span.SetAttributes(attribute.String("call.result", "no_url_configured"), attribute.String("provider", userConfig.Provider))
1137
4x
        return "", contextutils.WrapErrorf(contextutils.ErrAIConfigInvalid, "no base URL configured for provider '%s'", userConfig.Provider)
1138
4x
    }
1139

1140
4x
    userPrefix := ""
1141
4x
    if userConfig.Username != "" {
1142
        userPrefix = fmt.Sprintf("[user=%s] ", userConfig.Username)
1143
    }
1144

1145
4x
    s.logger.Debug(ctx, "Starting AI request", map[string]interface{}{
1146
4x
        "user_prefix": userPrefix,
1147
4x
        "url":         apiURL + "/chat/completions",
1148
4x
        "model":       model,
1149
4x
        "provider":    userConfig.Provider,
1150
4x
    })
1151
4x

1152
4x
    // Create messages with just the user prompt - grammar field will enforce JSON structure
1153
4x
    messages := []Message{{Role: "user", Content: prompt}}
1154
4x

1155
4x
    // Check if the provider supports grammar field
1156
4x
    supportsGrammar := s.supportsGrammarField(userConfig.Provider)
1157
4x

1158
4x
    reqBody := OpenAIRequest{
1159
4x
        Model:       model,
1160
4x
        Messages:    messages,
1161
4x
        Temperature: 0.7,
1162
4x
        MaxTokens:   s.getMaxTokensForModel(userConfig.Provider, userConfig.Model),
1163
4x
    }
1164
4x

1165
4x
    // Only include grammar field if the provider supports it
1166
4x
    if supportsGrammar && grammar != "" {
1167
4x
        reqBody.Grammar = grammar
1168
4x
    }
1169

1170
4x
    jsonData, err := json.Marshal(reqBody)
1171
4x
    if err != nil {
1172
        s.logger.Error(ctx, "Failed to marshal AI request", err, map[string]interface{}{
1173
            "user_prefix": userPrefix,
1174
        })
1175
        span.SetAttributes(attribute.String("call.result", "marshal_failed"), attribute.String("error", err.Error()))
1176
        return "", contextutils.WrapErrorf(err, "failed to marshal request body")
1177
    }
1178

1179
4x
    s.logger.Debug(ctx, "Making AI HTTP request", map[string]interface{}{
1180
4x
        "user_prefix": userPrefix,
1181
4x
        "url":         apiURL + "/chat/completions",
1182
4x
    })
1183
4x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL+"/chat/completions", bytes.NewBuffer(jsonData))
1184
4x
    if err != nil {
1185
        s.logger.Error(ctx, "Failed to create AI HTTP request", err, map[string]interface{}{
1186
            "user_prefix": userPrefix,
1187
        })
1188
        span.SetAttributes(attribute.String("call.result", "request_creation_failed"), attribute.String("error", err.Error()))
1189
        return "", contextutils.WrapErrorf(err, "failed to create HTTP request")
1190
    }
1191

1192
4x
    req.Header.Set("Content-Type", "application/json")
1193
4x
    req.Header.Set("User-Agent", "quizapp/1.0")
1194
4x
    if apiKey != "" {
1195
        req.Header.Set("Authorization", "Bearer "+apiKey)
1196
        s.logger.Debug(ctx, "Using API key authentication", map[string]interface{}{
1197
            "user_prefix": userPrefix,
1198
        })
1199
    } else {
1200
4x
        s.logger.Debug(ctx, "No API key provided, using anonymous access", map[string]interface{}{
1201
4x
            "user_prefix": userPrefix,
1202
4x
        })
1203
4x
    }
1204

1205
4x
    startTime := time.Now()
1206
4x
    resp, err := s.httpClient.Do(req.WithContext(ctx))
1207
4x
    duration := time.Since(startTime)
1208
4x

1209
4x
    if err != nil {
1210
1x
        s.logger.Error(ctx, "AI HTTP request failed", err, map[string]interface{}{
1211
1x
            "user_prefix": userPrefix,
1212
1x
            "duration":    duration.String(),
1213
1x
        })
1214
1x
        span.SetAttributes(attribute.String("call.result", "http_request_failed"), attribute.String("error", err.Error()), attribute.String("duration", duration.String()))
1215
1x
        return "", contextutils.WrapErrorf(err, "HTTP request failed after %v", duration)
1216
1x
    }
1217
3x
    defer func() {
1218
3x
        if err := resp.Body.Close(); err != nil {
1219
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{
1220
                "error": err.Error(),
1221
            })
1222
        }
1223
    }()
1224

1225
3x
    s.logger.Info(ctx, "AI Service HTTP request completed", map[string]interface{}{
1226
3x
        "user_prefix": userPrefix,
1227
3x
        "duration":    duration.String(),
1228
3x
        "status_code": resp.StatusCode,
1229
3x
    })
1230
3x

1231
3x
    body, err := io.ReadAll(resp.Body)
1232
3x
    if err != nil {
1233
        span.SetAttributes(attribute.String("call.result", "body_read_failed"), attribute.String("error", err.Error()))
1234
        return "", contextutils.WrapErrorf(err, "failed to read response body")
1235
    }
1236

1237
3x
    if resp.StatusCode != http.StatusOK {
1238
1x
        span.SetAttributes(attribute.String("call.result", "http_error"), attribute.Int("status_code", resp.StatusCode), attribute.String("body", string(body)))
1239
1x

1240
1x
        // Handle rate limit errors specifically
1241
1x
        if resp.StatusCode == http.StatusTooManyRequests {
1242
            return "", contextutils.WrapErrorf(contextutils.ErrRateLimit, "Rate limit exceeded for AI provider %s: %s", userConfig.Provider, string(body))
1243
        }
1244

1245
1x
        return "", contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "API request failed with status %d to %s: %s", resp.StatusCode, apiURL+"/chat/completions", string(body))
1246
    }
1247

1248
2x
    var openAIResp OpenAIResponse
1249
2x
    if err := json.Unmarshal(body, &openAIResp); err != nil {
1250
1x
        span.SetAttributes(attribute.String("call.result", "json_unmarshal_failed"), attribute.String("error", err.Error()), attribute.String("body", string(body)))
1251
1x
        return "", contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "failed to parse AI response as JSON: %w. Raw Response: %s", err, string(body))
1252
1x
    }
1253

1254
1x
    if openAIResp.Error != nil {
1255
        span.SetAttributes(attribute.String("call.result", "api_error"), attribute.String("error_message", openAIResp.Error.Message), attribute.String("error_type", openAIResp.Error.Type))
1256
        return "", contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "OpenAI API error: %s", openAIResp.Error.Message)
1257
    }
1258

1259
1x
    if len(openAIResp.Choices) == 0 {
1260
1x
        span.SetAttributes(attribute.String("call.result", "no_choices"))
1261
1x
        return "", contextutils.WrapError(contextutils.ErrAIResponseInvalid, "no response from OpenAI")
1262
1x
    }
1263

1264
    content := openAIResp.Choices[0].Message.Content
1265
    if content == "" {
1266
        span.SetAttributes(attribute.String("call.result", "empty_content"))
1267
        s.logger.Warn(ctx, "OpenAI returned empty content", map[string]interface{}{
1268
            "user_prefix":   userPrefix,
1269
            "response":      string(body),
1270
            "prompt_length": len(prompt),
1271
        })
1272
        return "", contextutils.WrapError(contextutils.ErrAIResponseInvalid, "AI returned empty content")
1273
    }
1274

1275
    span.SetAttributes(attribute.String("call.result", "success"), attribute.Int("content_length", len(content)), attribute.String("duration", duration.String()))
1276

1277
    // Extract usage information if available and track it internally
1278
    if openAIResp.Usage != nil {
1279
        userID := contextutils.GetUserIDFromContext(ctx)
1280
        apiKeyID := contextutils.GetAPIKeyIDFromContext(ctx)
1281
        s.trackAIUsage(ctx, userConfig, *openAIResp.Usage, userID, apiKeyID)
1282
    } else {
1283
        s.logger.Warn(ctx, "No usage information available", map[string]any{
1284
            "user_prefix":   userPrefix,
1285
            "response":      string(body),
1286
            "prompt_length": len(prompt),
1287
        })
1288
        span.SetAttributes(attribute.String("call.result", "no_usage_information"), attribute.String("response", string(body)), attribute.String("prompt_length", strconv.Itoa(len(prompt))))
1289
    }
1290

1291
    return content, nil
1292
}
1293

1294
// callOpenAIWithRetry attempts to call OpenAI with retry logic for empty content responses
1295
func (s *AIService) callOpenAIWithRetry(ctx context.Context, userConfig *models.UserAIConfig, prompt, grammar string) (result string, err error) {
1296
    _, span := observability.TraceAIFunction(ctx, "call_openai_with_retry",
1297
        attribute.String("ai.provider", userConfig.Provider),
1298
        attribute.String("ai.model", userConfig.Model),
1299
        attribute.String("ai.username", userConfig.Username),
1300
        attribute.Int("prompt.length", len(prompt)),
1301
        attribute.Bool("grammar.enabled", grammar != ""),
1302
    )
1303
    defer observability.FinishSpan(span, &err)
1304

1305
    const maxRetries = 2
1306
    var lastErr error
1307

1308
    for attempt := 0; attempt <= maxRetries; attempt++ {
1309
        if attempt > 0 {
1310
            // Add a small delay between retries
1311
            time.Sleep(time.Duration(attempt) * time.Second)
1312
        }
1313

1314
        result, err = s.callOpenAI(ctx, userConfig, prompt, grammar)
1315
        if err != nil {
1316
            // If it's not an empty content error, don't retry
1317
            if !contextutils.IsError(err, contextutils.ErrAIResponseInvalid) {
1318
                return result, err
1319
            }
1320

1321
            lastErr = err
1322

1323
            // If this is the last attempt, return the error
1324
            if attempt == maxRetries {
1325
                break
1326
            }
1327

1328
            s.logger.Warn(ctx, "Retrying AI request due to empty content", map[string]interface{}{
1329
                "attempt":     attempt + 1,
1330
                "max_retries": maxRetries,
1331
                "error":       err.Error(),
1332
            })
1333

1334
            continue
1335
        }
1336

1337
        return result, nil
1338
    }
1339

1340
    return result, contextutils.WrapErrorf(lastErr, "AI returned empty content after %d attempts", maxRetries+1)
1341
}
1342

1343
// callOpenAIStream makes a streaming request to the OpenAI-compatible API
1344
1x
func (s *AIService) callOpenAIStream(ctx context.Context, userConfig *models.UserAIConfig, prompt, grammar string, chunks chan<- string) (err error) {
1345
1x
    if userConfig == nil {
1346
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "userConfig is required")
1347
    }
1348
1x
    _, span := observability.TraceAIFunction(ctx, "call_openai_stream",
1349
1x
        attribute.String("ai.provider", userConfig.Provider),
1350
1x
        attribute.String("ai.model", userConfig.Model),
1351
1x
        attribute.String("ai.username", userConfig.Username),
1352
1x
        attribute.Int("prompt.length", len(prompt)),
1353
1x
        attribute.Bool("grammar.enabled", grammar != ""),
1354
1x
    )
1355
1x
    defer observability.FinishSpan(span, &err)
1356
1x

1357
1x
    // Validate input parameters
1358
1x
    if userConfig.Provider == "" {
1359
        span.SetAttributes(attribute.String("stream.result", "empty_provider"))
1360
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "provider is required")
1361
    }
1362

1363
1x
    if userConfig.Model == "" {
1364
        span.SetAttributes(attribute.String("stream.result", "empty_model"))
1365
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "model is required")
1366
    }
1367

1368
1x
    if prompt == "" {
1369
        span.SetAttributes(attribute.String("stream.result", "empty_prompt"))
1370
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "prompt cannot be empty")
1371
    }
1372

1373
1x
    if chunks == nil {
1374
        span.SetAttributes(attribute.String("stream.result", "nil_chunks_channel"))
1375
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "chunks channel is required")
1376
    }
1377

1378
1x
    apiURL := ""
1379
1x
    model := userConfig.Model
1380
1x
    apiKey := userConfig.APIKey
1381
1x

1382
1x
    // Look up the default URL from provider config
1383
1x
    if s.cfg.Providers != nil {
1384
1x
        for _, providerConfig := range s.cfg.Providers {
1385
2x
            if providerConfig.Code == userConfig.Provider && providerConfig.URL != "" {
1386
1x
                apiURL = providerConfig.URL
1387
1x
                break
1388
            }
1389
        }
1390
    }
1391

1392
1x
    if apiURL == "" {
1393
        span.SetAttributes(attribute.String("stream.result", "no_url_configured"), attribute.String("provider", userConfig.Provider))
1394
        return contextutils.WrapErrorf(contextutils.ErrAIConfigInvalid, "no base URL configured for provider '%s'", userConfig.Provider)
1395
    }
1396

1397
1x
    userPrefix := ""
1398
1x
    if userConfig.Username != "" {
1399
1x
        userPrefix = fmt.Sprintf("[user=%s] ", userConfig.Username)
1400
1x
    }
1401

1402
1x
    s.logger.Info(ctx, "AI Service Starting streaming request", map[string]interface{}{
1403
1x
        "user_prefix": userPrefix,
1404
1x
        "api_url":     apiURL + "/chat/completions",
1405
1x
        "model":       model,
1406
1x
        "provider":    userConfig.Provider,
1407
1x
    })
1408
1x

1409
1x
    // Create messages with just the user prompt - grammar field will enforce JSON structure
1410
1x
    messages := []Message{{Role: "user", Content: prompt}}
1411
1x

1412
1x
    // Check if the provider supports grammar field
1413
1x
    supportsGrammar := s.supportsGrammarField(userConfig.Provider)
1414
1x

1415
1x
    reqBody := OpenAIRequest{
1416
1x
        Model:       model,
1417
1x
        Messages:    messages,
1418
1x
        Temperature: 0.7,
1419
1x
        MaxTokens:   s.getMaxTokensForModel(userConfig.Provider, userConfig.Model),
1420
1x
        Stream:      true, // Enable streaming
1421
1x
    }
1422
1x

1423
1x
    // Only include grammar field if the provider supports it
1424
1x
    if supportsGrammar && grammar != "" {
1425
        reqBody.Grammar = grammar
1426
    }
1427

1428
1x
    jsonData, err := json.Marshal(reqBody)
1429
1x
    if err != nil {
1430
        s.logger.Error(ctx, "Failed to marshal request", err, map[string]interface{}{
1431
            "user_prefix": userPrefix,
1432
        })
1433
        span.SetAttributes(attribute.String("stream.result", "marshal_failed"), attribute.String("error", err.Error()))
1434
        return contextutils.WrapErrorf(err, "failed to marshal streaming request body")
1435
    }
1436

1437
1x
    s.logger.Info(ctx, "AI Service Making streaming HTTP request", map[string]interface{}{
1438
1x
        "user_prefix": userPrefix,
1439
1x
        "api_url":     apiURL + "/chat/completions",
1440
1x
    })
1441
1x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL+"/chat/completions", bytes.NewBuffer(jsonData))
1442
1x
    if err != nil {
1443
        s.logger.Error(ctx, "Failed to create HTTP request", err, map[string]interface{}{
1444
            "user_prefix": userPrefix,
1445
        })
1446
        span.SetAttributes(attribute.String("stream.result", "request_creation_failed"), attribute.String("error", err.Error()))
1447
        return contextutils.WrapErrorf(err, "failed to create streaming HTTP request")
1448
    }
1449

1450
1x
    req.Header.Set("Content-Type", "application/json")
1451
1x
    req.Header.Set("Accept", "text/event-stream")
1452
1x
    req.Header.Set("Cache-Control", "no-cache")
1453
1x
    req.Header.Set("User-Agent", "quizapp/1.0")
1454
1x
    if apiKey != "" {
1455
1x
        req.Header.Set("Authorization", "Bearer "+apiKey)
1456
1x
        s.logger.Info(ctx, "AI Service Using API key authentication", map[string]interface{}{
1457
1x
            "user_prefix": userPrefix,
1458
1x
        })
1459
1x
    } else {
1460
        s.logger.Info(ctx, "AI Service No API key provided, using anonymous access", map[string]interface{}{
1461
            "user_prefix": userPrefix,
1462
        })
1463
    }
1464

1465
1x
    startTime := time.Now()
1466
1x
    resp, err := s.httpClient.Do(req.WithContext(ctx))
1467
1x
    if err != nil {
1468
        s.logger.Error(ctx, "HTTP request failed", err, map[string]interface{}{
1469
            "user_prefix": userPrefix,
1470
        })
1471
        span.SetAttributes(attribute.String("stream.result", "http_request_failed"), attribute.String("error", err.Error()))
1472
        return contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "http client error: %w", err)
1473
    }
1474
1x
    defer func() {
1475
1x
        if err := resp.Body.Close(); err != nil {
1476
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{
1477
                "error": err.Error(),
1478
            })
1479
        }
1480
    }()
1481

1482
1x
    if resp.StatusCode != http.StatusOK {
1483
1x
        body, _ := io.ReadAll(resp.Body)
1484
1x
        span.SetAttributes(attribute.String("stream.result", "http_error"), attribute.Int("status_code", resp.StatusCode), attribute.String("body", string(body)))
1485
1x

1486
1x
        // Handle rate limit errors specifically
1487
1x
        if resp.StatusCode == http.StatusTooManyRequests {
1488
            return contextutils.WrapErrorf(contextutils.ErrRateLimit, "Rate limit exceeded for AI provider %s: %s", userConfig.Provider, string(body))
1489
        }
1490

1491
1x
        return contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "API request failed with status %d to %s: %s", resp.StatusCode, apiURL+"/chat/completions", string(body))
1492
    }
1493

1494
    s.logger.Info(ctx, "AI Service Streaming response started", map[string]interface{}{
1495
        "user_prefix": userPrefix,
1496
        "duration":    time.Since(startTime).String(),
1497
    })
1498

1499
    // Read the streaming response
1500
    scanner := bufio.NewScanner(resp.Body)
1501
    var chunkCount int
1502
    var totalContentLength int
1503
    var finalUsage *Usage
1504

1505
    // Usage information may or may not be included in streaming response chunks depending on the provider.
1506
    // We'll only try to extract usage from chunks for providers that support it in streaming responses.
1507
    // For providers that don't support usage in streaming, usage data is available via response.UsageMetadata in non-streaming calls.
1508

1509
    for scanner.Scan() {
1510
        line := scanner.Text()
1511

1512
        // Skip empty lines and comments
1513
        if line == "" || strings.HasPrefix(line, ":") {
1514
            continue
1515
        }
1516

1517
        // Parse Server-Sent Events format
1518
        if strings.HasPrefix(line, "data: ") {
1519
            data := strings.TrimPrefix(line, "data: ")
1520

1521
            // Check for end of stream
1522
            if data == "[DONE]" {
1523
                break
1524
            }
1525

1526
            // Parse the JSON chunk
1527
            var streamResp OpenAIStreamResponse
1528
            if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
1529
                s.logger.Warn(ctx, "AI Service WARNING: Failed to parse streaming chunk", map[string]interface{}{
1530
                    "error": err.Error(),
1531
                    "data":  data,
1532
                })
1533
                span.SetAttributes(attribute.String("stream.result", "chunk_parse_failed"), attribute.String("error", err.Error()), attribute.String("data", data))
1534
                continue
1535
            }
1536

1537
            if streamResp.Error != nil {
1538
                span.SetAttributes(attribute.String("stream.result", "api_streaming_error"), attribute.String("error_message", streamResp.Error.Message), attribute.String("error_type", streamResp.Error.Type))
1539
                return contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "OpenAI API streaming error: %s", streamResp.Error.Message)
1540
            }
1541

1542
            // Extract usage information if available (usually in the final chunk)
1543
            // Only check for usage if the provider supports it in streaming responses
1544
            if streamResp.Usage != nil && s.supportsUsageInStreaming(userConfig.Provider) {
1545
                finalUsage = streamResp.Usage
1546
            }
1547

1548
            // Extract content from the chunk
1549
            if len(streamResp.Choices) > 0 && streamResp.Choices[0].Delta.Content != "" {
1550
                content := streamResp.Choices[0].Delta.Content
1551
                totalContentLength += len(content)
1552

1553
                // Filter out thinking content for thinking models
1554
                filteredContent := s.filterThinkingContent(content, model)
1555

1556
                if filteredContent != "" {
1557
                    select {
1558
                    case chunks <- filteredContent:
1559
                        chunkCount++
1560
                    case <-ctx.Done():
1561
                        span.SetAttributes(attribute.String("stream.result", "context_cancelled"))
1562
                        return ctx.Err()
1563
                    }
1564
                }
1565
            }
1566

1567
            // Check if streaming is finished
1568
            if len(streamResp.Choices) > 0 && streamResp.Choices[0].FinishReason != nil {
1569
                break
1570
            }
1571
        }
1572
    }
1573

1574
    if err := scanner.Err(); err != nil {
1575
        span.SetAttributes(attribute.String("stream.result", "scanner_error"), attribute.String("error", err.Error()))
1576
        return contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "error reading streaming response: %w", err)
1577
    }
1578

1579
    s.logger.Info(ctx, "AI Service Streaming response completed", map[string]interface{}{
1580
        "user_prefix":          userPrefix,
1581
        "duration":             time.Since(startTime).String(),
1582
        "chunk_count":          chunkCount,
1583
        "total_content_length": totalContentLength,
1584
    })
1585

1586
    // Extract usage information if available and track it internally
1587
    if finalUsage != nil {
1588
        userID := contextutils.GetUserIDFromContext(ctx)
1589
        apiKeyID := contextutils.GetAPIKeyIDFromContext(ctx)
1590
        s.trackAIUsage(ctx, userConfig, *finalUsage, userID, apiKeyID)
1591
    } else {
1592
        // For providers that don't support usage in streaming, this is expected behavior
1593
        if !s.supportsUsageInStreaming(userConfig.Provider) {
1594
            s.logger.Debug(ctx, "No usage information in streaming response (expected - provider doesn't support usage in streaming)", map[string]any{
1595
                "user_prefix":     userPrefix,
1596
                "chunk_count":     chunkCount,
1597
                "content_length":  totalContentLength,
1598
                "provider":        userConfig.Provider,
1599
                "usage_supported": s.supportsUsageInStreaming(userConfig.Provider),
1600
            })
1601
        } else {
1602
            s.logger.Warn(ctx, "No usage information available in streaming response", map[string]any{
1603
                "user_prefix":    userPrefix,
1604
                "chunk_count":    chunkCount,
1605
                "content_length": totalContentLength,
1606
                "provider":       userConfig.Provider,
1607
            })
1608
        }
1609
        span.SetAttributes(attribute.String("stream.result", "no_usage_information"), attribute.Int("chunk_count", chunkCount), attribute.Int("content_length", totalContentLength))
1610
    }
1611

1612
    span.SetAttributes(attribute.String("stream.result", "success"), attribute.Int("chunk_count", chunkCount), attribute.Int("total_content_length", totalContentLength), attribute.String("duration", time.Since(startTime).String()))
1613
    return nil
1614
}
1615

1616
// filterThinkingContent filters out thinking sections for reasoning models
1617
3x
func (s *AIService) filterThinkingContent(content, model string) string {
1618
3x
    // Check if this is a thinking/reasoning model
1619
3x
    if !s.isThinkingModel(model) {
1620
1x
        return content
1621
1x
    }
1622

1623
    // For thinking models, filter out content between <thinking> tags
1624
2x
    if strings.Contains(content, "<thinking>") || strings.Contains(content, "</thinking>") {
1625
        return ""
1626
    }
1627

1628
2x
    if idx := strings.Index(content, "The answer is:"); idx != -1 {
1629
1x
        answer := content[idx+len("The answer is:"):]
1630
1x
        lines := strings.Split(answer, "\n")
1631
1x
        for _, line := range lines {
1632
1x
            trimmed := strings.TrimSpace(line)
1633
1x
            if trimmed != "" {
1634
1x
                return trimmed
1635
1x
            }
1636
        }
1637
        return ""
1638
    }
1639

1640
1x
    trimmed := strings.TrimSpace(content)
1641
1x
    if strings.HasPrefix(trimmed, "I need to") ||
1642
1x
        strings.HasPrefix(trimmed, "Let me think") ||
1643
1x
        strings.HasPrefix(trimmed, "First, I'll") {
1644
        return ""
1645
    }
1646

1647
1x
    return content
1648
}
1649

1650
// isThinkingModel checks if the model is a reasoning/thinking model
1651
9x
func (s *AIService) isThinkingModel(model string) bool {
1652
9x
    thinkingModels := []string{
1653
9x
        "o1-preview",
1654
9x
        "o1-mini",
1655
9x
        "o1",
1656
9x
        "qwen2.5-coder:32b",
1657
9x
        "deepseek-r1",
1658
9x
        "marco-o1",
1659
9x
        "gpt-4",
1660
9x
        "gpt-4-turbo",
1661
9x
        "claude-3",
1662
9x
    }
1663
9x

1664
9x
    modelLower := strings.ToLower(model)
1665
9x
    for _, thinkingModel := range thinkingModels {
1666
73x
        if strings.Contains(modelLower, strings.ToLower(thinkingModel)) {
1667
5x
            return true
1668
5x
        }
1669
    }
1670

1671
4x
    return false
1672
}
1673

1674
// cleanJSONResponse extracts JSON from markdown code blocks or returns the original response
1675
43x
func (s *AIService) cleanJSONResponse(ctx context.Context, response, provider string) string {
1676
43x
    _, span := observability.TraceAIFunction(ctx, "clean_json_response",
1677
43x
        attribute.String("ai.provider", provider),
1678
43x
        attribute.Int("response.length", len(response)),
1679
43x
    )
1680
43x
    defer span.End()
1681
43x
    // If the provider supports grammar field, we expect clean JSON
1682
43x
    if s.supportsGrammarField(provider) {
1683
1x
        return response
1684
1x
    }
1685

1686
    // For providers that don't support grammar field, clean up markdown code blocks
1687
41x
    response = strings.TrimSpace(response)
1688
41x

1689
41x
    // Remove markdown code block markers
1690
41x
    if strings.HasPrefix(response, "```json") {
1691
2x
        response = strings.TrimPrefix(response, "```json")
1692
2x
        response = strings.TrimSuffix(response, "```")
1693
2x
    } else if strings.HasPrefix(response, "```") {
1694
        response = strings.TrimPrefix(response, "```")
1695
1x
        response = strings.TrimSuffix(response, "```")
1696
1x
    }
1697

1698
41x
    return strings.TrimSpace(response)
1699
}
1700

1701
17x
func (s *AIService) parseQuestionsResponse(ctx context.Context, response, language, level string, qType models.QuestionType, provider string) (result0 []*models.Question, err error) {
1702
17x
    if s == nil {
1703
1x
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "AIService instance is nil")
1704
1x
    }
1705
16x
    _, span := observability.TraceAIFunction(ctx, "parse_questions_response",
1706
16x
        observability.AttributeQuestionType(qType),
1707
16x
        observability.AttributeLanguage(language),
1708
16x
        observability.AttributeLevel(level),
1709
16x
        attribute.String("ai.provider", provider),
1710
16x
        attribute.Int("response.length", len(response)),
1711
16x
    )
1712
16x
    defer observability.FinishSpan(span, &err)
1713
16x
    defer func() {
1714
16x
        if r := recover(); r != nil {
1715
            s.logger.Error(ctx, "PANIC in parseQuestionsResponse", nil, map[string]interface{}{
1716
                "panic":    fmt.Sprintf("%v", r),
1717
                "response": response,
1718
                "stack":    string(debug.Stack()),
1719
            })
1720
            span.SetAttributes(attribute.String("parse.result", "panic"), attribute.String("panic", fmt.Sprintf("%v", r)))
1721
        }
1722
    }()
1723

1724
    // Validate input parameters
1725
16x
    if response == "" {
1726
1x
        span.SetAttributes(attribute.String("parse.result", "empty_response"))
1727
1x
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "AI provider returned empty response")
1728
1x
    }
1729
15x
    if language == "" {
1730
        span.SetAttributes(attribute.String("parse.result", "empty_language"))
1731
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "language cannot be empty")
1732
    }
1733
15x
    if level == "" {
1734
        span.SetAttributes(attribute.String("parse.result", "empty_level"))
1735
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "level cannot be empty")
1736
    }
1737

1738
    // Clean the response to handle markdown code blocks for providers without grammar support
1739
15x
    cleanedResponse := s.cleanJSONResponse(ctx, response, provider)
1740
15x

1741
15x
    if cleanedResponse == "" {
1742
        span.SetAttributes(attribute.String("parse.result", "empty_cleaned_response"))
1743
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "AI provider returned empty response after cleaning")
1744
    }
1745

1746
    // With grammar field enforcement, we should get clean JSON directly
1747
    // No need for complex extraction - just parse the response directly
1748
15x
    var questions []map[string]interface{}
1749
15x
    if err := json.Unmarshal([]byte(cleanedResponse), &questions); err != nil {
1750
1x
        span.SetAttributes(attribute.String("parse.result", "json_unmarshal_failed"), attribute.String("error", err.Error()))
1751
1x
        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "failed to parse AI response as JSON: %w", err)
1752
1x
    }
1753

1754
14x
    if len(questions) == 0 {
1755
1x
        span.SetAttributes(attribute.String("parse.result", "no_questions_in_response"))
1756
1x
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "AI provider returned no questions in response")
1757
1x
    }
1758

1759
13x
    var result []*models.Question
1760
13x
    var validationErrors []string
1761
13x
    var skippedCount int
1762
13x

1763
13x
    for i, qData := range questions {
1764
1018x
        if qData == nil {
1765
3x
            skippedCount++
1766
3x
            span.SetAttributes(attribute.String("parse.result", "nil_question_data"), attribute.Int("question_index", i))
1767
3x
            continue
1768
        }
1769

1770
1015x
        question, err := s.createQuestionFromData(ctx, qData, language, level, qType)
1771
1015x
        if err != nil {
1772
9x
            // Try to extract more info about the failure
1773
9x
            var failedField, failedValue string
1774
9x
            for k, v := range qData {
1775
27x
                if v == nil || v == "" {
1776
2x
                    failedField = k
1777
2x
                    failedValue = fmt.Sprintf("%v", v)
1778
2x
                    break
1779
                }
1780
            }
1781
9x
            validationErrors = append(validationErrors, fmt.Sprintf("question %d: %v (field: %s, value: %s)", i+1, err, failedField, failedValue))
1782
9x
            span.SetAttributes(attribute.String("parse.result", "question_creation_failed"), attribute.Int("question_index", i), attribute.String("error", err.Error()))
1783
9x
            continue
1784
        }
1785

1786
1006x
        if question == nil {
1787
            skippedCount++
1788
            span.SetAttributes(attribute.String("parse.result", "nil_question_after_creation"), attribute.Int("question_index", i))
1789
            continue
1790
        }
1791

1792
        // Coerce correct_answer to int if it's a float64 (for schema validation)
1793
1006x
        if m := question.Content; m != nil {
1794
1006x
            if v, ok := m["correct_answer"]; ok {
1795
1006x
                switch val := v.(type) {
1796
1006x
                case float64:
1797
1006x
                    m["correct_answer"] = int(val)
1798
                }
1799
            }
1800
        }
1801

1802
1006x
        valid, err := s.ValidateQuestionSchema(ctx, qType, question)
1803
1006x
        if err != nil {
1804
            validationErrors = append(validationErrors, fmt.Sprintf("question %d schema validation error: %v", i+1, err))
1805
            span.SetAttributes(attribute.String("parse.result", "schema_validation_error"), attribute.Int("question_index", i), attribute.String("error", err.Error()))
1806
        }
1807

1808
1006x
        if !valid {
1809
            SchemaValidationMu.Lock()
1810
            SchemaValidationFailures[qType]++
1811
            if err != nil {
1812
                SchemaValidationFailureDetails[qType] = append(SchemaValidationFailureDetails[qType], err.Error())
1813
            } else {
1814
                SchemaValidationFailureDetails[qType] = append(SchemaValidationFailureDetails[qType], "validation failed")
1815
            }
1816
            if len(SchemaValidationFailureDetails[qType]) > 10 {
1817
                SchemaValidationFailureDetails[qType] = SchemaValidationFailureDetails[qType][len(SchemaValidationFailureDetails[qType])-10:]
1818
            }
1819
            SchemaValidationMu.Unlock()
1820
            skippedCount++
1821
            span.SetAttributes(attribute.String("parse.result", "schema_validation_failed"), attribute.Int("question_index", i))
1822
            continue // skip invalid question
1823
        }
1824

1825
1006x
        result = append(result, question)
1826
    }
1827

1828
    // Log validation summary
1829
13x
    if len(validationErrors) > 0 {
1830
8x
        s.logger.Warn(ctx, "AI Service WARNING: validation errors in response", map[string]interface{}{
1831
8x
            "validation_errors_count": len(validationErrors),
1832
8x
            "validation_errors":       strings.Join(validationErrors, "; "),
1833
8x
        })
1834
8x
        span.SetAttributes(attribute.String("parse.result", "validation_errors"), attribute.String("errors", strings.Join(validationErrors, "; ")))
1835
8x
    }
1836

1837
13x
    if len(result) == 0 {
1838
7x
        span.SetAttributes(attribute.String("parse.result", "no_valid_questions"), attribute.Int("total_questions", len(questions)), attribute.Int("skipped_count", skippedCount))
1839
7x
        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "AI provider returned only invalid or empty questions (total: %d, skipped: %d)", len(questions), skippedCount)
1840
7x
    }
1841

1842
6x
    span.SetAttributes(attribute.String("parse.result", "success"), attribute.Int("valid_questions", len(result)), attribute.Int("total_questions", len(questions)), attribute.Int("skipped_count", skippedCount))
1843
6x
    return result, nil
1844
}
1845

1846
// createQuestionFromData creates a Question from parsed JSON data
1847
2035x
func (s *AIService) createQuestionFromData(ctx context.Context, data map[string]interface{}, language, level string, qType models.QuestionType) (result0 *models.Question, err error) {
1848
2035x
    if s == nil {
1849
1x
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "AIService instance is nil")
1850
1x
    }
1851
2033x
    _, span := observability.TraceAIFunction(ctx, "create_question_from_data",
1852
2033x
        observability.AttributeQuestionType(qType),
1853
2033x
        observability.AttributeLanguage(language),
1854
2033x
        observability.AttributeLevel(level),
1855
2033x
        attribute.Int("data.fields", len(data)),
1856
2033x
    )
1857
2033x
    defer observability.FinishSpan(span, &err)
1858
2033x

1859
2033x
    if data == nil {
1860
1x
        span.SetAttributes(attribute.String("creation.result", "nil_data"))
1861
1x
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "question data is nil")
1862
1x
    }
1863

1864
    // Validate required parameters
1865
2031x
    if language == "" {
1866
        span.SetAttributes(attribute.String("creation.result", "empty_language"))
1867
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "language cannot be empty")
1868
    }
1869
2031x
    if level == "" {
1870
        span.SetAttributes(attribute.String("creation.result", "empty_level"))
1871
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "level cannot be empty")
1872
    }
1873

1874
2031x
    if ok, errMsg := s.validateQuestionContent(ctx, qType, data); !ok {
1875
9x
        missingFields := []string{}
1876
9x
        for k, v := range data {
1877
30x
            if v == nil || v == "" {
1878
2x
                missingFields = append(missingFields, k)
1879
2x
            }
1880
        }
1881
9x
        if len(missingFields) > 0 {
1882
2x
            span.SetAttributes(attribute.String("creation.result", "validation_failed_with_missing_fields"), attribute.String("missing_fields", strings.Join(missingFields, ",")))
1883
2x
            return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "invalid question content structure: %s. Missing or empty fields: %v", errMsg, missingFields)
1884
2x
        }
1885
7x
        span.SetAttributes(attribute.String("creation.result", "validation_failed"), attribute.String("error", errMsg))
1886
7x
        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "invalid question content structure: %s", errMsg)
1887
    }
1888

1889
    // Defensive: For reading comprehension, check passage, question, options, correct_answer
1890
2013x
    if qType == models.ReadingComprehension {
1891
1x
        if _, ok := data["passage"].(string); !ok {
1892
            span.SetAttributes(attribute.String("creation.result", "reading_missing_passage"))
1893
            return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "reading comprehension question missing or invalid 'passage' field")
1894
        }
1895
1x
        if _, ok := data["question"].(string); !ok {
1896
            span.SetAttributes(attribute.String("creation.result", "reading_missing_question"))
1897
            return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "reading comprehension question missing or invalid 'question' field")
1898
        }
1899
1x
        options, ok := data["options"].([]interface{})
1900
1x
        if !ok || len(options) != 4 {
1901
            span.SetAttributes(attribute.String("creation.result", "reading_invalid_options"))
1902
            return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "reading comprehension question missing or invalid 'options' field (must be array of 4 strings)")
1903
        }
1904
1x
        for i, opt := range options {
1905
4x
            if _, ok := opt.(string); !ok {
1906
                span.SetAttributes(attribute.String("creation.result", "reading_invalid_option_type"), attribute.Int("option_index", i))
1907
                return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "reading comprehension question 'options' must be array of strings, found invalid type at index %d", i)
1908
            }
1909
        }
1910
1x
        if _, ok := data["correct_answer"]; !ok {
1911
            span.SetAttributes(attribute.String("creation.result", "reading_missing_correct_answer"))
1912
            return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "reading comprehension question missing 'correct_answer' field")
1913
        }
1914
    }
1915

1916
    // Parse correct_answer as index (integer)
1917
2013x
    var correctAnswerIndex int
1918
2013x
    if correctAnswerRaw, exists := data["correct_answer"]; exists {
1919
2013x
        switch v := correctAnswerRaw.(type) {
1920
        case int:
1921
            correctAnswerIndex = v
1922
2013x
        case float64:
1923
2013x
            correctAnswerIndex = int(v)
1924
        case string:
1925
            // Handle string indices like "0", "1", "2", "3"
1926
            if idx, err := strconv.Atoi(v); err == nil {
1927
                correctAnswerIndex = idx
1928
            } else {
1929
                // Handle answer text - find index in options
1930
                if options, ok := data["options"].([]interface{}); ok {
1931
                    found := false
1932
                    for i, opt := range options {
1933
                        if optStr, ok := opt.(string); ok && optStr == v {
1934
                            correctAnswerIndex = i
1935
                            found = true
1936
                            break
1937
                        }
1938
                    }
1939
                    if !found {
1940
                        span.SetAttributes(attribute.String("creation.result", "correct_answer_not_found_in_options"))
1941
                        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "correct_answer '%s' not found in options", v)
1942
                    }
1943
                } else {
1944
                    span.SetAttributes(attribute.String("creation.result", "no_options_for_text_answer"))
1945
                    return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "correct_answer is text '%s' but no options available to match against", v)
1946
                }
1947
            }
1948
        default:
1949
            span.SetAttributes(attribute.String("creation.result", "invalid_correct_answer_type"), attribute.String("type", fmt.Sprintf("%T", v)))
1950
            return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "invalid correct_answer type: %T", v)
1951
        }
1952
    } else {
1953
        span.SetAttributes(attribute.String("creation.result", "missing_correct_answer"))
1954
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "missing correct_answer field")
1955
    }
1956

1957
    // Validate correct answer index
1958
2013x
    if options, ok := data["options"].([]interface{}); ok {
1959
2013x
        if correctAnswerIndex < 0 || correctAnswerIndex >= len(options) {
1960
            span.SetAttributes(attribute.String("creation.result", "invalid_correct_answer_index"), attribute.Int("index", correctAnswerIndex), attribute.Int("options_count", len(options)))
1961
            return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "correct_answer index %d is out of range (0-%d)", correctAnswerIndex, len(options)-1)
1962
        }
1963
    }
1964

1965
    // Note: Removed backend shuffling logic - frontend handles shuffling
1966
    // This prevents mismatch between backend and frontend answer indices
1967

1968
    // Get explanation or provide default
1969
2013x
    explanation, _ := data["explanation"].(string)
1970
2013x
    if explanation == "" {
1971
1005x
        // Provide a default explanation based on question type
1972
1005x
        switch qType {
1973
1005x
        case models.Vocabulary:
1974
1005x
            explanation = "This vocabulary question tests your knowledge of words in context."
1975
        case models.ReadingComprehension:
1976
            explanation = "This reading comprehension question tests your understanding of the passage."
1977
        case models.FillInBlank:
1978
            explanation = "This fill-in-the-blank question tests your grammar and vocabulary knowledge."
1979
        case models.QuestionAnswer:
1980
            explanation = "This question tests your conversational and practical language skills."
1981
        default:
1982
            explanation = "This question tests your language skills."
1983
        }
1984
        // Add the explanation to the data for schema validation
1985
1005x
        data["explanation"] = explanation
1986
    }
1987

1988
2013x
    question := &models.Question{
1989
2013x
        Type:            qType,
1990
2013x
        Language:        language,
1991
2013x
        Level:           level,
1992
2013x
        DifficultyScore: s.getDifficultyScore(level),
1993
2013x
        Content:         data,
1994
2013x
        CorrectAnswer:   correctAnswerIndex,
1995
2013x
        Explanation:     explanation,
1996
2013x
        CreatedAt:       time.Now(),
1997
2013x
    }
1998
2013x

1999
2013x
    span.SetAttributes(attribute.String("creation.result", "success"))
2000
2013x
    return question, nil
2001
}
2002

2003
3x
func (s *AIService) parseQuestionResponse(ctx context.Context, response, language, level string, qType models.QuestionType, provider string) (result0 *models.Question, err error) {
2004
3x
    _, span := observability.TraceAIFunction(ctx, "parse_question_response",
2005
3x
        observability.AttributeQuestionType(qType),
2006
3x
        observability.AttributeLanguage(language),
2007
3x
        observability.AttributeLevel(level),
2008
3x
        attribute.String("ai.provider", provider),
2009
3x
        attribute.Int("response.length", len(response)),
2010
3x
    )
2011
3x
    defer observability.FinishSpan(span, &err)
2012
3x
    // Clean the response to handle markdown code blocks for providers without grammar support
2013
3x
    cleanedResponse := s.cleanJSONResponse(ctx, response, provider)
2014
3x

2015
3x
    // With grammar field enforcement, we should get clean JSON directly
2016
3x
    // No need for complex extraction - just parse the response directly
2017
3x
    var data map[string]interface{}
2018
3x
    if err := json.Unmarshal([]byte(cleanedResponse), &data); err != nil {
2019
2x
        s.logger.Error(ctx, "Failed to parse JSON response", err, map[string]interface{}{
2020
2x
            "raw_response": response,
2021
2x
        })
2022
2x
        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "failed to parse AI response as JSON: %w", err)
2023
2x
    }
2024

2025
1x
    question, err := s.createQuestionFromData(ctx, data, language, level, qType)
2026
1x
    if err != nil {
2027
        s.logger.Error(ctx, "Failed to create question from data", err, map[string]interface{}{
2028
            "raw_question_data":   data,
2029
            "full_model_response": response,
2030
        })
2031
        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "failed to create question: %w", err)
2032
    }
2033
1x
    valid, err := s.ValidateQuestionSchema(ctx, qType, question)
2034
1x
    if err != nil {
2035
        s.logger.Error(ctx, "Schema validation error for question", err, nil)
2036
    }
2037
1x
    if !valid {
2038
        SchemaValidationMu.Lock()
2039
        SchemaValidationFailures[qType]++
2040
        if err != nil {
2041
            SchemaValidationFailureDetails[qType] = append(SchemaValidationFailureDetails[qType], err.Error())
2042
        } else {
2043
            SchemaValidationFailureDetails[qType] = append(SchemaValidationFailureDetails[qType], "validation failed")
2044
        }
2045
        if len(SchemaValidationFailureDetails[qType]) > 10 {
2046
            SchemaValidationFailureDetails[qType] = SchemaValidationFailureDetails[qType][len(SchemaValidationFailureDetails[qType])-10:]
2047
        }
2048
        SchemaValidationMu.Unlock()
2049
    }
2050
1x
    return question, nil
2051
}
2052

2053
2061x
func (s *AIService) getDifficultyScore(level string) float64 {
2054
2061x
    // Look up the level in the language levels configuration
2055
2061x
    if s.cfg != nil && s.cfg.LanguageLevels != nil {
2056
24x
        for _, langConfig := range s.cfg.LanguageLevels {
2057
92x
            for i, lvl := range langConfig.Levels {
2058
407x
                if lvl == level {
2059
21x
                    // Return a score based on the level's position (0.0 to 1.0)
2060
21x
                    return float64(i) / float64(len(langConfig.Levels)-1)
2061
21x
                }
2062
            }
2063
        }
2064
    }
2065
    // Default to middle difficulty if level not found
2066
2019x
    return 0.5
2067
}
2068

2069
2031x
func (s *AIService) validateQuestionContent(ctx context.Context, qType models.QuestionType, content map[string]interface{}) (bool, string) {
2070
2031x
    _, span := observability.TraceAIFunction(ctx, "validate_question_content",
2071
2031x
        observability.AttributeQuestionType(qType),
2072
2031x
        attribute.Int("content.fields", len(content)),
2073
2031x
    )
2074
2031x
    defer span.End()
2075
2031x

2076
2031x
    // Validate input parameters
2077
2031x
    if content == nil {
2078
        span.SetAttributes(attribute.String("validation.result", "nil_content"))
2079
        return false, "question content cannot be nil"
2080
    }
2081

2082
2031x
    requiredFields := make(map[string]func(interface{}) bool)
2083
2031x
    isString := func(v interface{}) bool {
2084
4057x
        if v == nil {
2085
1x
            return false
2086
1x
        }
2087
4055x
        _, ok := v.(string)
2088
4055x
        return ok && v.(string) != ""
2089
    }
2090
2031x
    isStringSlice := func(v interface{}) bool {
2091
2027x
        if v == nil {
2092
2x
            return false
2093
2x
        }
2094
2023x
        if slice, ok := v.([]interface{}); ok {
2095
2023x
            if len(slice) < 4 {
2096
1x
                return false
2097
1x
            }
2098
2021x
            for _, item := range slice {
2099
8084x
                if item == nil {
2100
                    return false
2101
                }
2102
8084x
                if _, ok := item.(string); !ok {
2103
                    return false
2104
                }
2105
8084x
                if item.(string) == "" {
2106
                    return false
2107
                }
2108
            }
2109
2021x
            return true
2110
        }
2111
        return false
2112
    }
2113
2031x
    isCorrectAnswer := func(v interface{}) bool {
2114
1x
        if v == nil {
2115
            return false
2116
        }
2117
1x
        switch val := v.(type) {
2118
        case int:
2119
            return val >= 0
2120
1x
        case float64:
2121
1x
            return val >= 0 && val == float64(int(val)) // Must be whole number
2122
        case string:
2123
            // Accept string indices like "0", "1", "2", "3" or answer text
2124
            if _, err := strconv.Atoi(val); err == nil {
2125
                return true
2126
            }
2127
            // Or accept answer text that matches one of the options
2128
            if options, ok := content["options"].([]interface{}); ok {
2129
                for _, opt := range options {
2130
                    if optStr, ok := opt.(string); ok && optStr == val {
2131
                        return true
2132
                    }
2133
                }
2134
            }
2135
            return false
2136
        default:
2137
            return false
2138
        }
2139
    }
2140

2141
2031x
    switch qType {
2142
2029x
    case models.Vocabulary:
2143
2029x
        requiredFields["sentence"] = isString
2144
2029x
        requiredFields["question"] = isString
2145
2029x
        requiredFields["options"] = isStringSlice
2146
2029x
        for field, validator := range requiredFields {
2147
6078x
            if !validator(content[field]) {
2148
5x
                span.SetAttributes(attribute.String("validation.result", "field_validation_failed"), attribute.String("field", field))
2149
5x
                return false, fmt.Sprintf("[Vocabulary] Validation failed for field '%s': %v", field, content[field])
2150
5x
            }
2151
        }
2152
2019x
        sentence, _ := content["sentence"].(string)
2153
2019x
        targetWord, _ := content["question"].(string)
2154
2019x
        options, _ := content["options"].([]interface{})
2155
2019x
        if sentence == "" || targetWord == "" || len(options) != 4 {
2156
            span.SetAttributes(attribute.String("validation.result", "vocabulary_structure_failed"))
2157
            return false, "[Vocabulary] Validation failed: missing or invalid sentence/question/options"
2158
        }
2159
2019x
        if !strings.Contains(sentence, targetWord) {
2160
4x
            span.SetAttributes(attribute.String("validation.result", "vocabulary_word_not_found"))
2161
4x
            return false, fmt.Sprintf("[Vocabulary] Validation failed: question '%s' not found in sentence '%s'", targetWord, sentence)
2162
4x
        }
2163
2011x
        span.SetAttributes(attribute.String("validation.result", "valid"))
2164
2011x
        return true, ""
2165

2166
1x
    case models.ReadingComprehension:
2167
1x
        requiredFields["passage"] = isString
2168
1x
        requiredFields["question"] = isString
2169
1x
        requiredFields["options"] = isStringSlice
2170
1x
        requiredFields["correct_answer"] = isCorrectAnswer
2171
1x
        for field, validator := range requiredFields {
2172
4x
            if !validator(content[field]) {
2173
                span.SetAttributes(attribute.String("validation.result", "field_validation_failed"), attribute.String("field", field))
2174
                return false, fmt.Sprintf("[ReadingComprehension] Validation failed for field '%s': %v", field, content[field])
2175
            }
2176
        }
2177
1x
        passage, _ := content["passage"].(string)
2178
1x
        if passage == "" {
2179
            span.SetAttributes(attribute.String("validation.result", "reading_passage_empty"))
2180
            return false, "[ReadingComprehension] Validation failed: passage cannot be empty"
2181
        }
2182
1x
        span.SetAttributes(attribute.String("validation.result", "valid"))
2183
1x
        return true, ""
2184

2185
    case models.FillInBlank:
2186
        // Fill-in-blank questions now use multiple choice format like all other types
2187
        requiredFields["question"] = isString
2188
        requiredFields["options"] = isStringSlice
2189
        requiredFields["correct_answer"] = isCorrectAnswer
2190
        for field, validator := range requiredFields {
2191
            if !validator(content[field]) {
2192
                span.SetAttributes(attribute.String("validation.result", "field_validation_failed"), attribute.String("field", field))
2193
                return false, fmt.Sprintf("[FillInBlank] Validation failed for field '%s': %v", field, content[field])
2194
            }
2195
        }
2196
        span.SetAttributes(attribute.String("validation.result", "valid"))
2197
        return true, ""
2198

2199
    case models.QuestionAnswer:
2200
        // Question-answer questions now use multiple choice format like all other types
2201
        requiredFields["question"] = isString
2202
        requiredFields["options"] = isStringSlice
2203
        requiredFields["correct_answer"] = isCorrectAnswer
2204
        for field, validator := range requiredFields {
2205
            if !validator(content[field]) {
2206
                span.SetAttributes(attribute.String("validation.result", "field_validation_failed"), attribute.String("field", field))
2207
                return false, fmt.Sprintf("[QuestionAnswer] Validation failed for field '%s': %v", field, content[field])
2208
            }
2209
        }
2210
        span.SetAttributes(attribute.String("validation.result", "valid"))
2211
        return true, ""
2212
    }
2213

2214
    // If we reach here, it's an unknown question type
2215
    span.SetAttributes(attribute.String("validation.result", "unknown_type"))
2216
    return false, fmt.Sprintf("unknown question type: %v", qType)
2217
}
2218

2219
// GetConcurrencyStats returns current concurrency metrics
2220
9x
func (s *AIService) GetConcurrencyStats() ConcurrencyStats {
2221
9x
    s.statsMu.RLock()
2222
9x
    s.concurrencyMu.RLock()
2223
9x
    defer s.statsMu.RUnlock()
2224
9x
    defer s.concurrencyMu.RUnlock()
2225
9x

2226
9x
    // Count active requests globally and per user
2227
9x
    queuedRequests := 0 // Currently we don't queue, we fail fast
2228
9x

2229
9x
    userActiveCount := make(map[string]int)
2230
9x
    for username, count := range s.userRequestCount {
2231
11x
        if count > 0 {
2232
1x
            userActiveCount[username] = count
2233
1x
        }
2234
    }
2235

2236
9x
    return ConcurrencyStats{
2237
9x
        ActiveRequests:  s.activeRequests,
2238
9x
        MaxConcurrent:   s.maxConcurrent,
2239
9x
        QueuedRequests:  queuedRequests,
2240
9x
        TotalRequests:   s.totalRequests,
2241
9x
        UserActiveCount: userActiveCount,
2242
9x
        MaxPerUser:      s.maxPerUser,
2243
9x
    }
2244
}
2245

2246
// acquireGlobalSlot attempts to acquire a global concurrency slot
2247
15x
func (s *AIService) acquireGlobalSlot(ctx context.Context) error {
2248
15x
    select {
2249
11x
    case s.globalSemaphore <- struct{}{}:
2250
11x
        return nil
2251
2x
    case <-ctx.Done():
2252
2x
        return contextutils.WrapErrorf(contextutils.ErrTimeout, "request cancelled while waiting for global AI slot: %w", ctx.Err())
2253
1x
    default:
2254
1x
        return contextutils.WrapErrorf(contextutils.ErrServiceUnavailable, "AI service at capacity (%d concurrent requests), please try again", s.maxConcurrent)
2255
    }
2256
}
2257

2258
// releaseGlobalSlot releases a global concurrency slot
2259
11x
func (s *AIService) releaseGlobalSlot(ctx context.Context) {
2260
11x
    s.concurrencyMu.Lock()
2261
11x
    defer s.concurrencyMu.Unlock()
2262
11x

2263
11x
    select {
2264
11x
    case <-s.globalSemaphore:
2265
11x
        // Successfully released a slot
2266
11x
        s.statsMu.Lock()
2267
11x
        if s.activeRequests > 0 {
2268
5x
            s.activeRequests--
2269
5x
        }
2270
11x
        s.statsMu.Unlock()
2271
    default:
2272
        // No slot was acquired
2273
        s.logger.Warn(ctx, "WARNING: Attempted to release global AI slot but none were acquired", nil)
2274
    }
2275
}
2276

2277
// acquireUserSlot acquires a user-specific concurrency slot
2278
15x
func (s *AIService) acquireUserSlot(_ context.Context, username string) error {
2279
15x
    s.concurrencyMu.Lock()
2280
15x
    defer s.concurrencyMu.Unlock()
2281
15x

2282
15x
    currentCount := s.userRequestCount[username]
2283
15x
    if currentCount >= s.maxPerUser {
2284
1x
        return contextutils.WrapErrorf(contextutils.ErrServiceUnavailable, "user concurrency limit exceeded for %s: %d/%d", username, currentCount, s.maxPerUser)
2285
1x
    }
2286

2287
13x
    s.userRequestCount[username] = currentCount + 1
2288
13x
    return nil
2289
}
2290

2291
// releaseUserSlot releases a user-specific concurrency slot
2292
13x
func (s *AIService) releaseUserSlot(ctx context.Context, username string) {
2293
13x
    s.concurrencyMu.Lock()
2294
13x
    defer s.concurrencyMu.Unlock()
2295
13x

2296
13x
    currentCount := s.userRequestCount[username]
2297
13x
    if currentCount > 0 {
2298
13x
        s.userRequestCount[username] = currentCount - 1
2299
13x
    } else {
2300
        s.logger.Warn(ctx, "WARNING: Attempted to release user AI slot but none were acquired", map[string]interface{}{
2301
            "username": username,
2302
        })
2303
    }
2304
}
2305

2306
// incrementTotalRequests increments the total request counter
2307
7x
func (s *AIService) incrementTotalRequests() {
2308
7x
    s.statsMu.Lock()
2309
7x
    defer s.statsMu.Unlock()
2310
7x
    s.totalRequests++
2311
7x
}
2312

2313
// withConcurrencyControl wraps an AI operation with concurrency limits
2314
9x
func (s *AIService) withConcurrencyControl(ctx context.Context, username string, operation func() error) error {
2315
9x
    // Check if service is shutting down
2316
9x
    if s.isShutdown() {
2317
1x
        return contextutils.WrapError(contextutils.ErrServiceUnavailable, "AI service is shutting down")
2318
1x
    }
2319

2320
    // Increment total request counter
2321
7x
    s.incrementTotalRequests()
2322
7x

2323
7x
    // Acquire global slot
2324
7x
    if err := s.acquireGlobalSlot(ctx); err != nil {
2325
2x
        return err
2326
2x
    }
2327

2328
    // Track active request
2329
5x
    s.statsMu.Lock()
2330
5x
    s.activeRequests++
2331
5x
    s.statsMu.Unlock()
2332
5x

2333
5x
    defer func() {
2334
5x
        s.releaseGlobalSlot(ctx)
2335
5x
    }()
2336

2337
    // Acquire per-user slot
2338
5x
    if err := s.acquireUserSlot(ctx, username); err != nil {
2339
        return err
2340
    }
2341
5x
    defer s.releaseUserSlot(ctx, username)
2342
5x

2343
5x
    // Execute the actual operation
2344
5x
    return operation()
2345
}
2346

2347
// supportsGrammarField checks if the provider supports the grammar field
2348
77x
func (s *AIService) supportsGrammarField(provider string) bool {
2349
77x
    // Check if the provider supports grammar field
2350
77x
    if s.cfg.Providers == nil {
2351
19x
        return false
2352
19x
    }
2353

2354
39x
    for _, providerConfig := range s.cfg.Providers {
2355
81x
        if providerConfig.Code == provider {
2356
32x
            return providerConfig.SupportsGrammar
2357
32x
        }
2358
    }
2359
7x
    return false
2360
}
2361

2362
// supportsUsageInStreaming checks if the provider supports usage tracking in streaming responses
2363
func (s *AIService) supportsUsageInStreaming(provider string) bool {
2364
    for _, providerConfig := range s.cfg.Providers {
2365
        if providerConfig.Code == provider {
2366
            return providerConfig.UsageSupported
2367
        }
2368
    }
2369
    return true
2370
}
2371

2372
// getQuestionBatchSize returns the maximum number of questions that can be generated in a single request for the given provider
2373
5x
func (s *AIService) getQuestionBatchSize(provider string) int {
2374
5x
    // Get the batch size for the provider
2375
5x
    if s.cfg.Providers == nil {
2376
        return 1 // Default batch size
2377
    }
2378

2379
5x
    for _, p := range s.cfg.Providers {
2380
10x
        if p.Code == provider {
2381
3x
            if p.QuestionBatchSize > 0 {
2382
3x
                return p.QuestionBatchSize
2383
3x
            }
2384
            break
2385
        }
2386
    }
2387
2x
    return 1 // Default batch size
2388
}
2389

2390
// GetQuestionBatchSize returns the maximum number of questions that can be generated in a single request for the given provider
2391
func (s *AIService) GetQuestionBatchSize(provider string) int {
2392
    return s.getQuestionBatchSize(provider)
2393
}
2394

2395
// VarietyService returns the variety service used by the AI service
2396
1x
func (s *AIService) VarietyService() *VarietyService {
2397
1x
    return s.varietyService
2398
1x
}
2399

2400
// TemplateManager exposes template rendering and example loading for prompts
2401
func (s *AIService) TemplateManager() *AITemplateManager {
2402
    return s.templateManager
2403
}
2404

2405
// SupportsGrammarField reports whether the provider supports the grammar field
2406
func (s *AIService) SupportsGrammarField(provider string) bool {
2407
    return s.supportsGrammarField(provider)
2408
}
2409

2410
// CallWithPrompt sends a raw prompt (and optional grammar) to the provider and returns the response
2411
func (s *AIService) CallWithPrompt(ctx context.Context, userConfig *models.UserAIConfig, prompt, grammar string) (string, error) {
2412
    return s.callOpenAI(ctx, userConfig, prompt, grammar)
2413
}
2414

2415
// trackAIUsage tracks AI usage statistics
2416
4x
func (s *AIService) trackAIUsage(ctx context.Context, userConfig *models.UserAIConfig, usage Usage, userID int, apiKeyID *int) {
2417
4x
    // Skip recording if userID is invalid (0 means no user context)
2418
4x
    if userID == 0 {
2419
1x
        s.logger.Error(ctx, "Skipping AI usage tracking - no valid user ID in context", nil, map[string]interface{}{
2420
1x
            "provider":          userConfig.Provider,
2421
1x
            "model":             userConfig.Model,
2422
1x
            "prompt_tokens":     usage.PromptTokens,
2423
1x
            "completion_tokens": usage.CompletionTokens,
2424
1x
            "total_tokens":      usage.TotalTokens,
2425
1x
        })
2426
1x
        return
2427
1x
    }
2428

2429
    // TODO: Determine usage type based on the context (this is a simple heuristic)
2430
3x
    usageType := "generic" // Default assumption
2431
3x

2432
3x
    // Record usage in the usage stats service
2433
3x
    err := s.usageStatsSvc.RecordUserAITokenUsage(
2434
3x
        ctx,
2435
3x
        userID,
2436
3x
        apiKeyID,
2437
3x
        userConfig.Provider,
2438
3x
        userConfig.Model,
2439
3x
        usageType,
2440
3x
        usage.PromptTokens,
2441
3x
        usage.CompletionTokens,
2442
3x
        usage.TotalTokens,
2443
3x
        1, // requests
2444
3x
    )
2445
3x
    if err != nil {
2446
1x
        s.logger.Warn(ctx, "Failed to record AI usage", map[string]interface{}{
2447
1x
            "error":   err.Error(),
2448
1x
            "user_id": userID,
2449
1x
        })
2450
1x
    }
2451
}
2452


			
quizapp internal services worker_service.go
78.6%
Statements
11/14
1
// Package services provides embedded templates for AI service prompts
2
package services
3

4
import (
5
    "embed"
6
    "fmt"
7
    "strings"
8
    "text/template"
9

10
    contextutils "quizapp/internal/utils"
11
)
12

13
//go:embed templates/*.tmpl
14
var aiTemplatesFS embed.FS
15

16
//go:embed templates/examples/*.json
17
var exampleFilesFS embed.FS
18

19
// Template names as constants
20
const (
21
    BatchQuestionPromptTemplate         = "batch_question_prompt.tmpl"
22
    ChatPromptTemplate                  = "chat_prompt.tmpl"
23
    JSONStructureGuidanceTemplate       = "json_structure_guidance.tmpl"
24
    AIFixPromptTemplate                 = "ai_fix_prompt.tmpl"
25
    TranslationSentencePromptTemplate   = "translation_sentence_prompt.tmpl"
26
    TranslationEvaluationPromptTemplate = "translation_evaluation_prompt.tmpl"
27
)
28

29
// AITemplateData holds data for rendering AI prompt templates
30
type AITemplateData struct {
31
    // Common fields
32
    Language              string
33
    Level                 string
34
    QuestionType          string
35
    Topic                 string
36
    RecentQuestionHistory []string
37
    ReportReasons         []string
38
    Count                 int // For batch generation
39

40
    // Variety fields for question generation
41
    TopicCategory      string
42
    GrammarFocus       string
43
    VocabularyDomain   string
44
    Scenario           string
45
    StyleModifier      string
46
    DifficultyModifier string
47
    TimeContext        string
48

49
    // Schema and formatting
50
    SchemaForPrompt     string // for direct inclusion in prompt for non-grammar providers
51
    ExampleContent      string // for including example in prompt
52
    CurrentQuestionJSON string // the actual question JSON to pass into ai-fix prompt
53
    AdditionalContext   string // optional freeform context provided by admin when requesting AI fix
54

55
    // Explanation specific
56
    Question      string
57
    UserAnswer    string
58
    CorrectAnswer string // The text of the correct answer for explanations
59

60
    // Chat specific
61
    Passage             string
62
    Options             []string
63
    IsCorrect           *bool
64
    ConversationHistory []ChatMessage
65
    UserMessage         string
66

67
    // Priority-aware generation fields (NEW)
68
    UserWeakAreas        []string
69
    HighPriorityTopics   []string
70
    GapAnalysis          map[string]int
71
    FocusOnWeakAreas     bool
72
    FreshQuestionRatio   float64
73
    PriorityDistribution map[string]int
74

75
    // Story generation fields
76
    Title              string
77
    Subject            string
78
    AuthorStyle        string
79
    TimePeriod         string
80
    Genre              string
81
    Tone               string
82
    CharacterNames     string
83
    CustomInstructions string
84
    TargetWords        int
85
    TargetSentences    int
86
    IsFirstSection     bool
87
    PreviousSections   string
88
    SectionText        string
89

90
    // Translation practice fields
91
    Direction            string // Translation direction
92
    OriginalSentence     string
93
    UserTranslation      string
94
    SourceLanguage       string
95
    TargetLanguage       string
96
    TranslationDirection string
97
}
98

99
// ChatMessage represents a chat message for templates
100
type ChatMessage struct {
101
    Role    string
102
    Content string
103
}
104

105
// AITemplateManager manages AI prompt templates
106
type AITemplateManager struct {
107
    templates *template.Template
108
}
109

110
// NewAITemplateManager creates a new template manager
111
86x
func NewAITemplateManager() (result0 *AITemplateManager, err error) {
112
86x
    templates, err := template.New("").ParseFS(aiTemplatesFS, "templates/*.tmpl")
113
86x
    if err != nil {
114
        return nil, err
115
    }
116

117
86x
    return &AITemplateManager{
118
86x
        templates: templates,
119
86x
    }, nil
120
}
121

122
// RenderTemplate renders a template with the given data
123
41x
func (tm *AITemplateManager) RenderTemplate(templateName string, data AITemplateData) (result0 string, err error) {
124
41x
    var buf strings.Builder
125
41x
    err = tm.templates.ExecuteTemplate(&buf, templateName, data)
126
41x
    if err != nil {
127
        return "", err
128
    }
129
41x
    return buf.String(), nil
130
}
131

132
// LoadExample loads the example JSON for a specific question type
133
19x
func (tm *AITemplateManager) LoadExample(questionType string) (result0 string, err error) {
134
19x
    examplePath := fmt.Sprintf("templates/examples/%s_example.json", questionType)
135
19x
    content, err := exampleFilesFS.ReadFile(examplePath)
136
19x
    if err != nil {
137
        return "", contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to load example for %s: %w", questionType, err)
138
    }
139
19x
    return string(content), nil
140
}
141


			
quizapp internal services worker_service.go
80.5%
Statements
132/164
1
package services
2

3
import (
4
    "context"
5
    "crypto/rand"
6
    "database/sql"
7
    "encoding/hex"
8
    "errors"
9
    "time"
10

11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    contextutils "quizapp/internal/utils"
14

15
    "go.opentelemetry.io/otel/attribute"
16
    "go.opentelemetry.io/otel/codes"
17
    "golang.org/x/crypto/bcrypt"
18
)
19

20
// AuthAPIKeyServiceInterface defines the interface for auth API key operations
21
type AuthAPIKeyServiceInterface interface {
22
    CreateAPIKey(ctx context.Context, userID int, keyName, permissionLevel string) (*models.AuthAPIKey, string, error)
23
    ListAPIKeys(ctx context.Context, userID int) ([]models.AuthAPIKey, error)
24
    GetAPIKeyByID(ctx context.Context, userID, keyID int) (*models.AuthAPIKey, error)
25
    DeleteAPIKey(ctx context.Context, userID, keyID int) error
26
    ValidateAPIKey(ctx context.Context, rawKey string) (*models.AuthAPIKey, error)
27
    UpdateLastUsed(ctx context.Context, keyID int) error
28
}
29

30
// AuthAPIKeyService implements AuthAPIKeyServiceInterface
31
type AuthAPIKeyService struct {
32
    db     *sql.DB
33
    logger *observability.Logger
34
}
35

36
// NewAuthAPIKeyService creates a new AuthAPIKeyService instance
37
3x
func NewAuthAPIKeyService(db *sql.DB, logger *observability.Logger) *AuthAPIKeyService {
38
3x
    return &AuthAPIKeyService{
39
3x
        db:     db,
40
3x
        logger: logger,
41
3x
    }
42
3x
}
43

44
const (
45
    // KeyPrefix is the prefix for all auth API keys
46
    KeyPrefix = "qapp_"
47
    // KeyLength is the length of the random part of the key (32 characters)
48
    KeyLength = 32
49
)
50

51
// generateAPIKey generates a new random API key
52
1x
func generateAPIKey() (string, error) {
53
1x
    // Generate 32 random bytes
54
1x
    randomBytes := make([]byte, KeyLength/2) // 16 bytes = 32 hex characters
55
1x
    if _, err := rand.Read(randomBytes); err != nil {
56
        return "", contextutils.WrapErrorf(err, "failed to generate random key: %w", err)
57
    }
58

59
    // Convert to hex string
60
1x
    randomStr := hex.EncodeToString(randomBytes)
61
1x

62
1x
    // Add prefix
63
1x
    return KeyPrefix + randomStr, nil
64
}
65

66
// hashAPIKey hashes an API key using bcrypt
67
1x
func hashAPIKey(key string) (string, error) {
68
1x
    hash, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost)
69
1x
    if err != nil {
70
        return "", contextutils.WrapErrorf(err, "failed to hash API key: %w", err)
71
    }
72
1x
    return string(hash), nil
73
}
74

75
// CreateAPIKey creates a new API key for a user
76
3x
func (s *AuthAPIKeyService) CreateAPIKey(ctx context.Context, userID int, keyName, permissionLevel string) (*models.AuthAPIKey, string, error) {
77
3x
    ctx, span := observability.TraceFunction(ctx, "auth_api_key_service", "create_api_key")
78
3x
    defer observability.FinishSpan(span, nil)
79
3x

80
3x
    span.SetAttributes(
81
3x
        attribute.Int("user_id", userID),
82
3x
        attribute.String("key_name", keyName),
83
3x
        attribute.String("permission_level", permissionLevel),
84
3x
    )
85
3x

86
3x
    // Validate permission level
87
3x
    if !models.IsValidPermissionLevel(permissionLevel) {
88
1x
        err := contextutils.NewAppError(
89
1x
            contextutils.ErrorCodeInvalidInput,
90
1x
            contextutils.SeverityWarn,
91
1x
            "Invalid permission level",
92
1x
            "Permission level must be 'readonly' or 'full'",
93
1x
        )
94
1x
        span.RecordError(err)
95
1x
        span.SetStatus(codes.Error, err.Error())
96
1x
        return nil, "", err
97
1x
    }
98

99
    // Validate key name
100
2x
    if keyName == "" {
101
1x
        err := contextutils.NewAppError(
102
1x
            contextutils.ErrorCodeInvalidInput,
103
1x
            contextutils.SeverityWarn,
104
1x
            "Key name is required",
105
1x
            "",
106
1x
        )
107
1x
        span.RecordError(err)
108
1x
        span.SetStatus(codes.Error, err.Error())
109
1x
        return nil, "", err
110
1x
    }
111

112
    // Generate new API key
113
1x
    rawKey, err := generateAPIKey()
114
1x
    if err != nil {
115
        span.RecordError(err)
116
        span.SetStatus(codes.Error, "failed to generate API key")
117
        return nil, "", contextutils.WrapError(err, "failed to generate API key")
118
    }
119

120
    // Hash the key
121
1x
    keyHash, err := hashAPIKey(rawKey)
122
1x
    if err != nil {
123
        span.RecordError(err)
124
        span.SetStatus(codes.Error, "failed to hash API key")
125
        return nil, "", contextutils.WrapError(err, "failed to hash API key")
126
    }
127

128
    // Extract key prefix (first 12 characters including "qapp_")
129
1x
    keyPrefix := rawKey
130
1x
    if len(rawKey) > 12 {
131
1x
        keyPrefix = rawKey[:12]
132
1x
    }
133

134
    // Insert into database
135
1x
    query := `
136
1x
        INSERT INTO auth_api_keys (user_id, key_name, key_hash, key_prefix, permission_level, created_at, updated_at)
137
1x
        VALUES ($1, $2, $3, $4, $5, $6, $7)
138
1x
        RETURNING id, created_at, updated_at
139
1x
    `
140
1x

141
1x
    now := time.Now()
142
1x
    var apiKey models.AuthAPIKey
143
1x
    apiKey.UserID = userID
144
1x
    apiKey.KeyName = keyName
145
1x
    apiKey.KeyHash = keyHash
146
1x
    apiKey.KeyPrefix = keyPrefix
147
1x
    apiKey.PermissionLevel = permissionLevel
148
1x

149
1x
    err = s.db.QueryRowContext(ctx, query, userID, keyName, keyHash, keyPrefix, permissionLevel, now, now).
150
1x
        Scan(&apiKey.ID, &apiKey.CreatedAt, &apiKey.UpdatedAt)
151
1x
    if err != nil {
152
        s.logger.Error(ctx, "Failed to create API key", err, map[string]interface{}{
153
            "user_id":          userID,
154
            "key_name":         keyName,
155
            "permission_level": permissionLevel,
156
        })
157
        span.RecordError(err)
158
        span.SetStatus(codes.Error, "failed to insert API key")
159
        return nil, "", contextutils.WrapError(err, "failed to create API key")
160
    }
161

162
1x
    span.SetAttributes(attribute.Int("api_key_id", apiKey.ID))
163
1x
    s.logger.Info(ctx, "Created new API key", map[string]interface{}{
164
1x
        "user_id":          userID,
165
1x
        "api_key_id":       apiKey.ID,
166
1x
        "key_name":         keyName,
167
1x
        "permission_level": permissionLevel,
168
1x
    })
169
1x

170
1x
    // Return the API key object and the raw key (only time it's returned)
171
1x
    return &apiKey, rawKey, nil
172
}
173

174
// ListAPIKeys returns all API keys for a user
175
8x
func (s *AuthAPIKeyService) ListAPIKeys(ctx context.Context, userID int) ([]models.AuthAPIKey, error) {
176
8x
    ctx, span := observability.TraceFunction(ctx, "auth_api_key_service", "list_api_keys")
177
8x
    defer observability.FinishSpan(span, nil)
178
8x

179
8x
    span.SetAttributes(attribute.Int("user_id", userID))
180
8x

181
8x
    query := `
182
8x
        SELECT id, user_id, key_name, key_hash, key_prefix, permission_level, last_used_at, created_at, updated_at
183
8x
        FROM auth_api_keys
184
8x
        WHERE user_id = $1
185
8x
        ORDER BY created_at DESC
186
8x
    `
187
8x

188
8x
    rows, err := s.db.QueryContext(ctx, query, userID)
189
8x
    if err != nil {
190
1x
        s.logger.Error(ctx, "Failed to list API keys", err, map[string]interface{}{"user_id": userID})
191
1x
        span.RecordError(err)
192
1x
        span.SetStatus(codes.Error, "failed to query API keys")
193
1x
        return nil, contextutils.WrapError(err, "failed to list API keys")
194
1x
    }
195
6x
    defer func() { _ = rows.Close() }()
196

197
6x
    var apiKeys []models.AuthAPIKey
198
6x
    for rows.Next() {
199
3x
        var apiKey models.AuthAPIKey
200
3x
        err := rows.Scan(
201
3x
            &apiKey.ID,
202
3x
            &apiKey.UserID,
203
3x
            &apiKey.KeyName,
204
3x
            &apiKey.KeyHash,
205
3x
            &apiKey.KeyPrefix,
206
3x
            &apiKey.PermissionLevel,
207
3x
            &apiKey.LastUsedAt,
208
3x
            &apiKey.CreatedAt,
209
3x
            &apiKey.UpdatedAt,
210
3x
        )
211
3x
        if err != nil {
212
1x
            s.logger.Error(ctx, "Failed to scan API key", err, map[string]interface{}{"user_id": userID})
213
1x
            span.RecordError(err)
214
1x
            span.SetStatus(codes.Error, "failed to scan API key")
215
1x
            return nil, contextutils.WrapError(err, "failed to scan API key")
216
1x
        }
217
1x
        apiKeys = append(apiKeys, apiKey)
218
    }
219

220
4x
    if err := rows.Err(); err != nil {
221
1x
        s.logger.Error(ctx, "Error iterating API keys", err, map[string]interface{}{"user_id": userID})
222
1x
        span.RecordError(err)
223
1x
        span.SetStatus(codes.Error, "failed to iterate API keys")
224
1x
        return nil, contextutils.WrapError(err, "failed to list API keys")
225
1x
    }
226

227
2x
    span.SetAttributes(attribute.Int("count", len(apiKeys)))
228
2x
    return apiKeys, nil
229
}
230

231
// GetAPIKeyByID retrieves a specific API key by ID for a user
232
3x
func (s *AuthAPIKeyService) GetAPIKeyByID(ctx context.Context, userID, keyID int) (*models.AuthAPIKey, error) {
233
3x
    ctx, span := observability.TraceFunction(ctx, "auth_api_key_service", "get_api_key_by_id")
234
3x
    defer observability.FinishSpan(span, nil)
235
3x

236
3x
    span.SetAttributes(
237
3x
        attribute.Int("user_id", userID),
238
3x
        attribute.Int("key_id", keyID),
239
3x
    )
240
3x

241
3x
    query := `
242
3x
        SELECT id, user_id, key_name, key_hash, key_prefix, permission_level, last_used_at, created_at, updated_at
243
3x
        FROM auth_api_keys
244
3x
        WHERE id = $1 AND user_id = $2
245
3x
    `
246
3x

247
3x
    var apiKey models.AuthAPIKey
248
3x
    err := s.db.QueryRowContext(ctx, query, keyID, userID).Scan(
249
3x
        &apiKey.ID,
250
3x
        &apiKey.UserID,
251
3x
        &apiKey.KeyName,
252
3x
        &apiKey.KeyHash,
253
3x
        &apiKey.KeyPrefix,
254
3x
        &apiKey.PermissionLevel,
255
3x
        &apiKey.LastUsedAt,
256
3x
        &apiKey.CreatedAt,
257
3x
        &apiKey.UpdatedAt,
258
3x
    )
259
3x

260
3x
    if err == sql.ErrNoRows {
261
1x
        return nil, nil
262
1x
    }
263

264
1x
    if err != nil {
265
        s.logger.Error(ctx, "Failed to get API key", err, map[string]interface{}{
266
            "user_id": userID,
267
            "key_id":  keyID,
268
        })
269
        span.RecordError(err)
270
        span.SetStatus(codes.Error, "failed to get API key")
271
        return nil, contextutils.WrapError(err, "failed to get API key")
272
    }
273

274
1x
    return &apiKey, nil
275
}
276

277
// DeleteAPIKey deletes an API key
278
2x
func (s *AuthAPIKeyService) DeleteAPIKey(ctx context.Context, userID, keyID int) error {
279
2x
    ctx, span := observability.TraceFunction(ctx, "auth_api_key_service", "delete_api_key")
280
2x
    defer observability.FinishSpan(span, nil)
281
2x

282
2x
    span.SetAttributes(
283
2x
        attribute.Int("user_id", userID),
284
2x
        attribute.Int("key_id", keyID),
285
2x
    )
286
2x

287
2x
    query := `DELETE FROM auth_api_keys WHERE id = $1 AND user_id = $2`
288
2x

289
2x
    result, err := s.db.ExecContext(ctx, query, keyID, userID)
290
2x
    if err != nil {
291
        s.logger.Error(ctx, "Failed to delete API key", err, map[string]interface{}{
292
            "user_id": userID,
293
            "key_id":  keyID,
294
        })
295
        span.RecordError(err)
296
        span.SetStatus(codes.Error, "failed to delete API key")
297
        return contextutils.WrapError(err, "failed to delete API key")
298
    }
299

300
2x
    rowsAffected, err := result.RowsAffected()
301
2x
    if err != nil {
302
        s.logger.Error(ctx, "Failed to get rows affected", err, map[string]interface{}{
303
            "user_id": userID,
304
            "key_id":  keyID,
305
        })
306
        span.RecordError(err)
307
        span.SetStatus(codes.Error, "failed to get rows affected")
308
        return contextutils.WrapError(err, "failed to check deletion")
309
    }
310

311
2x
    if rowsAffected == 0 {
312
1x
        err := contextutils.NewAppError(
313
1x
            contextutils.ErrorCodeRecordNotFound,
314
1x
            contextutils.SeverityWarn,
315
1x
            "API key not found",
316
1x
            "",
317
1x
        )
318
1x
        span.RecordError(err)
319
1x
        span.SetStatus(codes.Error, "API key not found")
320
1x
        return err
321
1x
    }
322

323
1x
    s.logger.Info(ctx, "Deleted API key", map[string]interface{}{
324
1x
        "user_id": userID,
325
1x
        "key_id":  keyID,
326
1x
    })
327
1x

328
1x
    return nil
329
}
330

331
// ValidateAPIKey validates a raw API key and returns the associated key info
332
5x
func (s *AuthAPIKeyService) ValidateAPIKey(ctx context.Context, rawKey string) (*models.AuthAPIKey, error) {
333
5x
    ctx, span := observability.TraceFunction(ctx, "auth_api_key_service", "validate_api_key")
334
5x
    defer observability.FinishSpan(span, nil)
335
5x

336
5x
    // Basic validation
337
5x
    if rawKey == "" {
338
1x
        return nil, errors.New("API key is empty")
339
1x
    }
340

341
4x
    if len(rawKey) < len(KeyPrefix) || rawKey[:len(KeyPrefix)] != KeyPrefix {
342
1x
        span.SetStatus(codes.Error, "invalid API key format")
343
1x
        return nil, errors.New("invalid API key format")
344
1x
    }
345

346
    // Query all API keys with matching prefix for this key
347
    // We need to check all because we hash the keys
348
3x
    query := `
349
3x
        SELECT id, user_id, key_name, key_hash, key_prefix, permission_level, last_used_at, created_at, updated_at
350
3x
        FROM auth_api_keys
351
3x
    `
352
3x

353
3x
    rows, err := s.db.QueryContext(ctx, query)
354
3x
    if err != nil {
355
        s.logger.Error(ctx, "Failed to query API keys for validation", err, nil)
356
        span.RecordError(err)
357
        span.SetStatus(codes.Error, "failed to query API keys")
358
        return nil, contextutils.WrapError(err, "failed to validate API key")
359
    }
360
3x
    defer func() { _ = rows.Close() }()
361

362
    // Check each key by comparing bcrypt hash
363
3x
    for rows.Next() {
364
1x
        var apiKey models.AuthAPIKey
365
1x
        err := rows.Scan(
366
1x
            &apiKey.ID,
367
1x
            &apiKey.UserID,
368
1x
            &apiKey.KeyName,
369
1x
            &apiKey.KeyHash,
370
1x
            &apiKey.KeyPrefix,
371
1x
            &apiKey.PermissionLevel,
372
1x
            &apiKey.LastUsedAt,
373
1x
            &apiKey.CreatedAt,
374
1x
            &apiKey.UpdatedAt,
375
1x
        )
376
1x
        if err != nil {
377
            s.logger.Error(ctx, "Failed to scan API key", err, nil)
378
            continue
379
        }
380

381
        // Compare hash
382
1x
        err = bcrypt.CompareHashAndPassword([]byte(apiKey.KeyHash), []byte(rawKey))
383
1x
        if err == nil {
384
1x
            // Found matching key
385
1x
            span.SetAttributes(
386
1x
                attribute.Int("api_key_id", apiKey.ID),
387
1x
                attribute.Int("user_id", apiKey.UserID),
388
1x
                attribute.String("permission_level", apiKey.PermissionLevel),
389
1x
            )
390
1x
            return &apiKey, nil
391
1x
        }
392
    }
393

394
1x
    if err := rows.Err(); err != nil {
395
1x
        s.logger.Error(ctx, "Error iterating API keys", err, nil)
396
1x
        span.RecordError(err)
397
1x
        span.SetStatus(codes.Error, "failed to iterate API keys")
398
1x
        return nil, contextutils.WrapError(err, "failed to validate API key")
399
1x
    }
400

401
    // No matching key found
402
    span.SetStatus(codes.Error, "invalid API key")
403
    return nil, errors.New("invalid API key")
404
}
405

406
// UpdateLastUsed updates the last_used_at timestamp for an API key
407
// This should be called asynchronously to avoid blocking requests
408
3x
func (s *AuthAPIKeyService) UpdateLastUsed(ctx context.Context, keyID int) error {
409
3x
    ctx, span := observability.TraceFunction(ctx, "auth_api_key_service", "update_last_used")
410
3x
    defer observability.FinishSpan(span, nil)
411
3x

412
3x
    span.SetAttributes(attribute.Int("key_id", keyID))
413
3x

414
3x
    query := `UPDATE auth_api_keys SET last_used_at = $1, updated_at = $2 WHERE id = $3`
415
3x

416
3x
    now := time.Now()
417
3x
    _, err := s.db.ExecContext(ctx, query, now, now, keyID)
418
3x
    if err != nil {
419
1x
        s.logger.Error(ctx, "Failed to update last used timestamp", err, map[string]interface{}{
420
1x
            "key_id": keyID,
421
1x
        })
422
1x
        span.RecordError(err)
423
1x
        span.SetStatus(codes.Error, "failed to update last used")
424
1x
        // Don't return error - this is not critical
425
1x
        return nil
426
1x
    }
427

428
1x
    return nil
429
}
430


			
quizapp internal services worker_service.go
83.5%
Statements
86/103
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "time"
8

9
    "go.opentelemetry.io/otel/attribute"
10
    "go.opentelemetry.io/otel/codes"
11
    "go.opentelemetry.io/otel/trace"
12

13
    "quizapp/internal/observability"
14
)
15

16
// CleanupService handles database maintenance and cleanup tasks
17
type CleanupService struct {
18
    db     *sql.DB
19
    logger *observability.Logger
20
}
21

22
// NewCleanupServiceWithLogger creates a new cleanup service with logger
23
27x
func NewCleanupServiceWithLogger(db *sql.DB, logger *observability.Logger) *CleanupService {
24
27x
    return &CleanupService{
25
27x
        db:     db,
26
27x
        logger: logger,
27
27x
    }
28
27x
}
29

30
// CleanupLegacyQuestionTypes removes questions with unsupported question types
31
12x
func (c *CleanupService) CleanupLegacyQuestionTypes(ctx context.Context) (err error) {
32
12x
    ctx, span := observability.TraceCleanupFunction(ctx, "cleanup_legacy_question_types")
33
12x
    defer func() {
34
12x
        if err != nil {
35
4x
            span.RecordError(err, trace.WithStackTrace(true))
36
4x
            span.SetStatus(codes.Error, err.Error())
37
4x
        }
38
12x
        span.End()
39
    }()
40

41
    // Check if database is available
42
12x
    if c.db == nil {
43
3x
        return errors.New("database connection not available")
44
3x
    }
45

46
    // Get count of legacy questions first
47
9x
    var count int
48
9x
    err = c.db.QueryRowContext(ctx, `
49
9x
        SELECT COUNT(*)
50
9x
        FROM questions
51
9x
        WHERE type NOT IN ('vocabulary', 'fill_blank', 'qa', 'reading_comprehension')
52
9x
    `).Scan(&count)
53
9x
    if err != nil {
54
1x
        span.SetAttributes(attribute.String("error", err.Error()))
55
1x
        return err
56
1x
    }
57

58
8x
    span.SetAttributes(attribute.Int("cleanup.legacy_questions_count", count))
59
8x

60
8x
    if count == 0 {
61
5x
        c.logger.Info(ctx, "No legacy question types found to cleanup", map[string]interface{}{})
62
5x
        span.SetAttributes(attribute.String("cleanup.result", "no_legacy_questions"))
63
5x
        return nil
64
5x
    }
65

66
3x
    c.logger.Info(ctx, "Found questions with legacy types to cleanup", map[string]interface{}{"count": count})
67
3x

68
3x
    // Delete questions with unsupported types
69
3x
    result, err := c.db.ExecContext(ctx, `
70
3x
        DELETE FROM questions
71
3x
        WHERE type NOT IN ('vocabulary', 'fill_blank', 'qa', 'reading_comprehension')
72
3x
    `)
73
3x
    if err != nil {
74
        span.SetAttributes(attribute.String("error", err.Error()))
75
        return err
76
    }
77

78
3x
    rowsAffected, err := result.RowsAffected()
79
3x
    if err != nil {
80
        span.SetAttributes(attribute.String("error", err.Error()))
81
        return err
82
    }
83

84
3x
    span.SetAttributes(
85
3x
        attribute.Int64("cleanup.rows_affected", rowsAffected),
86
3x
        attribute.String("cleanup.result", "success"),
87
3x
    )
88
3x

89
3x
    c.logger.Info(ctx, "Successfully cleaned up questions with legacy types", map[string]interface{}{"rows_affected": rowsAffected})
90
3x
    return nil
91
}
92

93
// CleanupOrphanedResponses removes user responses for questions that no longer exist
94
4x
func (c *CleanupService) CleanupOrphanedResponses(ctx context.Context) (err error) {
95
4x
    ctx, span := observability.TraceCleanupFunction(ctx, "cleanup_orphaned_responses")
96
4x
    defer func() {
97
4x
        if err != nil {
98
1x
            span.RecordError(err, trace.WithStackTrace(true))
99
1x
            span.SetStatus(codes.Error, err.Error())
100
1x
        }
101
4x
        span.End()
102
    }()
103

104
    // Check if database is available
105
4x
    if c.db == nil {
106
1x
        return errors.New("database connection not available")
107
1x
    }
108

109
3x
    var count int
110
3x
    err = c.db.QueryRowContext(ctx, `
111
3x
        SELECT COUNT(*)
112
3x
        FROM user_responses ur
113
3x
        LEFT JOIN questions q ON ur.question_id = q.id
114
3x
        WHERE q.id IS NULL
115
3x
    `).Scan(&count)
116
3x
    if err != nil {
117
        span.SetAttributes(attribute.String("error", err.Error()))
118
        return err
119
    }
120

121
3x
    span.SetAttributes(attribute.Int("cleanup.orphaned_responses_count", count))
122
3x

123
3x
    if count == 0 {
124
2x
        c.logger.Info(ctx, "No orphaned responses found to cleanup", map[string]interface{}{})
125
2x
        span.SetAttributes(attribute.String("cleanup.result", "no_orphaned_responses"))
126
2x
        return nil
127
2x
    }
128

129
1x
    c.logger.Info(ctx, "Found orphaned responses to cleanup", map[string]interface{}{"count": count})
130
1x

131
1x
    result, err := c.db.ExecContext(ctx, `
132
1x
        DELETE FROM user_responses
133
1x
        WHERE question_id NOT IN (SELECT id FROM questions)
134
1x
    `)
135
1x
    if err != nil {
136
        span.SetAttributes(attribute.String("error", err.Error()))
137
        return err
138
    }
139

140
1x
    rowsAffected, err := result.RowsAffected()
141
1x
    if err != nil {
142
        span.SetAttributes(attribute.String("error", err.Error()))
143
        return err
144
    }
145

146
1x
    span.SetAttributes(
147
1x
        attribute.Int64("cleanup.rows_affected", rowsAffected),
148
1x
        attribute.String("cleanup.result", "success"),
149
1x
    )
150
1x

151
1x
    c.logger.Info(ctx, "Successfully cleaned up orphaned responses", map[string]interface{}{"rows_affected": rowsAffected})
152
1x
    return nil
153
}
154

155
// RunFullCleanup performs all cleanup operations
156
2x
func (c *CleanupService) RunFullCleanup(ctx context.Context) (err error) {
157
2x
    ctx, span := observability.TraceCleanupFunction(ctx, "run_full_cleanup")
158
2x
    defer func() {
159
2x
        if err != nil {
160
1x
            span.RecordError(err, trace.WithStackTrace(true))
161
1x
            span.SetStatus(codes.Error, err.Error())
162
1x
        }
163
2x
        span.End()
164
    }()
165

166
2x
    span.SetAttributes(attribute.String("cleanup.start_time", time.Now().Format(time.RFC3339)))
167
2x

168
2x
    c.logger.Info(ctx, "Starting database cleanup", map[string]interface{}{"start_time": time.Now().Format(time.RFC3339)})
169
2x

170
2x
    if err = c.CleanupLegacyQuestionTypes(ctx); err != nil {
171
1x
        c.logger.Error(ctx, "Failed to cleanup legacy question types", err, map[string]interface{}{})
172
1x
        span.SetAttributes(attribute.String("error", err.Error()))
173
1x
        return err
174
1x
    }
175

176
1x
    if err := c.CleanupOrphanedResponses(ctx); err != nil {
177
        c.logger.Error(ctx, "Failed to cleanup orphaned responses", err, map[string]interface{}{})
178
        span.SetAttributes(attribute.String("error", err.Error()))
179
        return err
180
    }
181

182
1x
    span.SetAttributes(
183
1x
        attribute.String("cleanup.end_time", time.Now().Format(time.RFC3339)),
184
1x
        attribute.String("cleanup.result", "success"),
185
1x
    )
186
1x

187
1x
    c.logger.Info(ctx, "Database cleanup completed successfully", map[string]interface{}{"end_time": time.Now().Format(time.RFC3339)})
188
1x
    return nil
189
}
190

191
// GetCleanupStats returns statistics about cleanup operations
192
2x
func (c *CleanupService) GetCleanupStats(ctx context.Context) (result0 map[string]int, err error) {
193
2x
    ctx, span := observability.TraceCleanupFunction(ctx, "get_cleanup_stats")
194
2x
    defer func() {
195
2x
        if err != nil {
196
1x
            span.RecordError(err, trace.WithStackTrace(true))
197
1x
            span.SetStatus(codes.Error, err.Error())
198
1x
        }
199
2x
        span.End()
200
    }()
201

202
    // Check if database is available
203
2x
    if c.db == nil {
204
1x
        return nil, errors.New("database connection not available")
205
1x
    }
206

207
1x
    stats := make(map[string]int)
208
1x

209
1x
    // Count legacy question types
210
1x
    var legacyCount int
211
1x
    err = c.db.QueryRowContext(ctx, `
212
1x
        SELECT COUNT(*)
213
1x
        FROM questions
214
1x
        WHERE type NOT IN ('vocabulary', 'fill_blank', 'qa', 'reading_comprehension')
215
1x
    `).Scan(&legacyCount)
216
1x
    if err != nil {
217
        span.SetAttributes(attribute.String("error", err.Error()))
218
        return nil, err
219
    }
220
1x
    stats["legacy_questions"] = legacyCount
221
1x

222
1x
    // Count orphaned responses
223
1x
    var orphanedCount int
224
1x
    err = c.db.QueryRowContext(ctx, `
225
1x
        SELECT COUNT(*)
226
1x
        FROM user_responses ur
227
1x
        LEFT JOIN questions q ON ur.question_id = q.id
228
1x
        WHERE q.id IS NULL
229
1x
    `).Scan(&orphanedCount)
230
1x
    if err != nil {
231
        span.SetAttributes(attribute.String("error", err.Error()))
232
        return nil, err
233
    }
234
1x
    stats["orphaned_responses"] = orphanedCount
235
1x

236
1x
    span.SetAttributes(
237
1x
        attribute.Int("cleanup.stats.legacy_questions", legacyCount),
238
1x
        attribute.Int("cleanup.stats.orphaned_responses", orphanedCount),
239
1x
    )
240
1x

241
1x
    return stats, nil
242
}
243


			
quizapp internal services worker_service.go
45.8%
Statements
125/273
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "encoding/json"
7
    "fmt"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/api"
12
    contextutils "quizapp/internal/utils"
13

14
    "github.com/google/uuid"
15
)
16

17
// ConversationServiceInterface defines the interface for AI conversation operations
18
type ConversationServiceInterface interface {
19
    // Conversation CRUD operations
20
    CreateConversation(ctx context.Context, userID uint, req *api.CreateConversationRequest) (*api.Conversation, error)
21
    GetConversation(ctx context.Context, conversationID string, userID uint) (*api.Conversation, error)
22
    GetUserConversations(ctx context.Context, userID uint, limit, offset int) ([]api.Conversation, int, error)
23
    UpdateConversation(ctx context.Context, conversationID string, userID uint, req *api.UpdateConversationRequest) (*api.Conversation, error)
24
    DeleteConversation(ctx context.Context, conversationID string, userID uint) error
25

26
    // Message operations
27
    AddMessage(ctx context.Context, conversationID string, userID uint, req *api.CreateMessageRequest) (*api.ChatMessage, error)
28
    GetConversationMessages(ctx context.Context, conversationID string, userID uint) ([]api.ChatMessage, error)
29
    ToggleMessageBookmark(ctx context.Context, conversationID, messageID string, userID uint) (bool, error)
30

31
    // Search operations
32
    SearchMessages(ctx context.Context, userID uint, query string, limit, offset int) ([]api.ChatMessage, int, error)
33
    SearchConversations(ctx context.Context, userID uint, query string, limit, offset int) ([]api.Conversation, int, error)
34

35
    // Bookmark operations
36
    GetBookmarkedMessages(ctx context.Context, userID uint, query string, limit, offset int) ([]api.ChatMessage, int, error)
37

38
    // Utility operations
39
    // GetUserMessageCounts returns a map of conversation ID -> message count for the user's conversations
40
    GetUserMessageCounts(ctx context.Context, userID uint) (map[string]int, error)
41
}
42

43
// ConversationService handles all AI conversation-related operations
44
type ConversationService struct {
45
    db *sql.DB
46
}
47

48
// NewConversationService creates a new ConversationService
49
1x
func NewConversationService(db *sql.DB) *ConversationService {
50
1x
    return &ConversationService{
51
1x
        db: db,
52
1x
    }
53
1x
}
54

55
// CreateConversation creates a new AI conversation
56
10x
func (s *ConversationService) CreateConversation(ctx context.Context, userID uint, req *api.CreateConversationRequest) (*api.Conversation, error) {
57
10x
    conversationID := uuid.New()
58
10x

59
10x
    query := `
60
10x
        INSERT INTO ai_conversations (id, user_id, title, created_at, updated_at)
61
10x
        VALUES ($1, $2, $3, $4, $5)
62
10x
        RETURNING id, user_id, title, created_at, updated_at`
63
10x

64
10x
    var conversation api.Conversation
65
10x
    err := s.db.QueryRowContext(ctx, query,
66
10x
        conversationID,
67
10x
        userID,
68
10x
        req.Title,
69
10x
        time.Now(),
70
10x
        time.Now(),
71
10x
    ).Scan(
72
10x
        &conversation.Id,
73
10x
        &conversation.UserId,
74
10x
        &conversation.Title,
75
10x
        &conversation.CreatedAt,
76
10x
        &conversation.UpdatedAt,
77
10x
    )
78
10x
    if err != nil {
79
        return nil, contextutils.WrapError(err, "failed to create conversation")
80
    }
81

82
10x
    return &conversation, nil
83
}
84

85
// GetConversation retrieves a conversation with all its messages
86
3x
func (s *ConversationService) GetConversation(ctx context.Context, conversationID string, userID uint) (*api.Conversation, error) {
87
3x
    // First get the conversation
88
3x
    query := `
89
3x
        SELECT id, user_id, title, created_at, updated_at
90
3x
        FROM ai_conversations
91
3x
        WHERE id = $1 AND user_id = $2`
92
3x

93
3x
    var conversation api.Conversation
94
3x
    err := s.db.QueryRowContext(ctx, query, conversationID, userID).Scan(
95
3x
        &conversation.Id,
96
3x
        &conversation.UserId,
97
3x
        &conversation.Title,
98
3x
        &conversation.CreatedAt,
99
3x
        &conversation.UpdatedAt,
100
3x
    )
101
3x
    if err != nil {
102
1x
        if err == sql.ErrNoRows {
103
1x
            return nil, contextutils.ErrorWithContextf("conversation not found")
104
1x
        }
105
        return nil, contextutils.WrapError(err, "failed to get conversation")
106
    }
107

108
    // Get the messages for this conversation
109
2x
    messages, err := s.GetConversationMessages(ctx, conversationID, userID)
110
2x
    if err != nil {
111
        return nil, contextutils.WrapError(err, "failed to get conversation messages")
112
    }
113

114
    // Ensure messages is never nil - always point to a valid slice
115
2x
    if messages == nil {
116
1x
        messages = []api.ChatMessage{}
117
1x
    }
118
2x
    conversation.Messages = &messages
119
2x

120
2x
    return &conversation, nil
121
}
122

123
// GetUserConversations retrieves all conversations for a user with pagination
124
2x
func (s *ConversationService) GetUserConversations(ctx context.Context, userID uint, limit, offset int) ([]api.Conversation, int, error) {
125
2x
    // Get total count
126
2x
    countQuery := `SELECT COUNT(*) FROM ai_conversations WHERE user_id = $1`
127
2x
    var total int
128
2x
    err := s.db.QueryRowContext(ctx, countQuery, userID).Scan(&total)
129
2x
    if err != nil {
130
        return nil, 0, contextutils.WrapError(err, "failed to count conversations")
131
    }
132

133
    // Get conversations with pagination
134
2x
    query := `
135
2x
        SELECT id, user_id, title, created_at, updated_at
136
2x
        FROM ai_conversations
137
2x
        WHERE user_id = $1
138
2x
        ORDER BY updated_at DESC
139
2x
        LIMIT $2 OFFSET $3`
140
2x

141
2x
    rows, err := s.db.QueryContext(ctx, query, userID, limit, offset)
142
2x
    if err != nil {
143
        return nil, 0, contextutils.WrapError(err, "failed to query conversations")
144
    }
145
2x
    defer func() { _ = rows.Close() }()
146

147
2x
    var conversations []api.Conversation
148
2x
    for rows.Next() {
149
3x
        var conv api.Conversation
150
3x
        err := rows.Scan(
151
3x
            &conv.Id,
152
3x
            &conv.UserId,
153
3x
            &conv.Title,
154
3x
            &conv.CreatedAt,
155
3x
            &conv.UpdatedAt,
156
3x
        )
157
3x
        if err != nil {
158
            return nil, 0, contextutils.WrapError(err, "failed to scan conversation")
159
        }
160
3x
        conversations = append(conversations, conv)
161
    }
162

163
2x
    if err := rows.Err(); err != nil {
164
        return nil, 0, contextutils.WrapError(err, "error iterating conversations")
165
    }
166

167
2x
    return conversations, total, nil
168
}
169

170
// GetUserMessageCounts returns message counts for all conversations for a user
171
func (s *ConversationService) GetUserMessageCounts(ctx context.Context, userID uint) (map[string]int, error) {
172
    query := `
173
        SELECT c.id::text AS id, COUNT(m.id) AS message_count
174
        FROM ai_conversations c
175
        LEFT JOIN ai_chat_messages m ON m.conversation_id = c.id
176
        WHERE c.user_id = $1
177
        GROUP BY c.id`
178

179
    rows, err := s.db.QueryContext(ctx, query, userID)
180
    if err != nil {
181
        return nil, contextutils.WrapError(err, "failed to query message counts")
182
    }
183
    defer func() { _ = rows.Close() }()
184

185
    counts := make(map[string]int)
186
    for rows.Next() {
187
        var id string
188
        var count int
189
        if err := rows.Scan(&id, &count); err != nil {
190
            return nil, contextutils.WrapError(err, "failed to scan message count")
191
        }
192
        counts[id] = count
193
    }
194
    if err := rows.Err(); err != nil {
195
        return nil, contextutils.WrapError(err, "error iterating message counts")
196
    }
197
    return counts, nil
198
}
199

200
// UpdateConversation updates a conversation's title
201
1x
func (s *ConversationService) UpdateConversation(ctx context.Context, conversationID string, userID uint, req *api.UpdateConversationRequest) (*api.Conversation, error) {
202
1x
    query := `
203
1x
        UPDATE ai_conversations
204
1x
        SET title = $1, updated_at = $2
205
1x
        WHERE id = $3 AND user_id = $4
206
1x
        RETURNING id, user_id, title, created_at, updated_at`
207
1x

208
1x
    var conversation api.Conversation
209
1x
    err := s.db.QueryRowContext(ctx, query,
210
1x
        req.Title,
211
1x
        time.Now(),
212
1x
        conversationID,
213
1x
        userID,
214
1x
    ).Scan(
215
1x
        &conversation.Id,
216
1x
        &conversation.UserId,
217
1x
        &conversation.Title,
218
1x
        &conversation.CreatedAt,
219
1x
        &conversation.UpdatedAt,
220
1x
    )
221
1x
    if err != nil {
222
        if err == sql.ErrNoRows {
223
            return nil, contextutils.ErrorWithContextf("conversation not found")
224
        }
225
        return nil, contextutils.WrapError(err, "failed to update conversation")
226
    }
227

228
1x
    return &conversation, nil
229
}
230

231
// DeleteConversation deletes a conversation and all its messages
232
1x
func (s *ConversationService) DeleteConversation(ctx context.Context, conversationID string, userID uint) error {
233
1x
    // First verify the conversation belongs to the user
234
1x
    var ownerID uint
235
1x
    err := s.db.QueryRowContext(ctx, "SELECT user_id FROM ai_conversations WHERE id = $1", conversationID).Scan(&ownerID)
236
1x
    if err != nil {
237
        if err == sql.ErrNoRows {
238
            return contextutils.ErrorWithContextf("conversation not found")
239
        }
240
        return contextutils.WrapError(err, "failed to verify conversation ownership")
241
    }
242

243
1x
    if ownerID != userID {
244
        return contextutils.ErrorWithContextf("conversation not found")
245
    }
246

247
    // Delete the conversation (CASCADE will delete associated messages)
248
1x
    query := `DELETE FROM ai_conversations WHERE id = $1 AND user_id = $2`
249
1x
    result, err := s.db.ExecContext(ctx, query, conversationID, userID)
250
1x
    if err != nil {
251
        return contextutils.WrapError(err, "failed to delete conversation")
252
    }
253

254
1x
    rowsAffected, err := result.RowsAffected()
255
1x
    if err != nil {
256
        return contextutils.WrapError(err, "failed to get rows affected")
257
    }
258

259
1x
    if rowsAffected == 0 {
260
        return contextutils.ErrorWithContextf("conversation not found")
261
    }
262

263
1x
    return nil
264
}
265

266
// AddMessage adds a new message to a conversation
267
7x
func (s *ConversationService) AddMessage(ctx context.Context, conversationID string, userID uint, req *api.CreateMessageRequest) (*api.ChatMessage, error) {
268
7x
    // First verify the conversation belongs to the user
269
7x
    var ownerID uint
270
7x
    err := s.db.QueryRowContext(ctx, "SELECT user_id FROM ai_conversations WHERE id = $1", conversationID).Scan(&ownerID)
271
7x
    if err != nil {
272
        if err == sql.ErrNoRows {
273
            return nil, contextutils.ErrorWithContextf("conversation not found")
274
        }
275
        return nil, contextutils.WrapError(err, "failed to verify conversation ownership")
276
    }
277

278
7x
    if ownerID != userID {
279
        return nil, contextutils.ErrorWithContextf("conversation not found")
280
    }
281

282
7x
    messageID := uuid.New()
283
7x
    query := `
284
7x
        INSERT INTO ai_chat_messages (id, conversation_id, question_id, role, answer_json, bookmarked, created_at, updated_at)
285
7x
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
286
7x
        RETURNING id, conversation_id, question_id, role, answer_json, bookmarked, created_at, updated_at`
287
7x

288
7x
    var message api.ChatMessage
289
7x
    var questionIDPtr *int
290
7x
    if req.QuestionId != nil {
291
        questionIDPtr = req.QuestionId
292
    }
293

294
    // Store content directly as JSON string
295
7x
    contentJSON, err := json.Marshal(req.Content)
296
7x
    if err != nil {
297
        return nil, contextutils.WrapError(err, "failed to marshal message content")
298
    }
299

300
7x
    var contentBytes []byte
301
7x
    err = s.db.QueryRowContext(ctx, query,
302
7x
        messageID,
303
7x
        conversationID,
304
7x
        questionIDPtr,
305
7x
        string(req.Role),
306
7x
        contentJSON, // Store as JSON string value
307
7x
        false,       // bookmarked defaults to false
308
7x
        time.Now(),
309
7x
        time.Now(),
310
7x
    ).Scan(
311
7x
        &message.Id,
312
7x
        &message.ConversationId,
313
7x
        &message.QuestionId,
314
7x
        &message.Role,
315
7x
        &contentBytes,
316
7x
        &message.Bookmarked,
317
7x
        &message.CreatedAt,
318
7x
        &message.UpdatedAt,
319
7x
    )
320
7x
    if err != nil {
321
        return nil, contextutils.WrapError(err, "failed to add message")
322
    }
323

324
    // Unmarshal the content from bytes
325
7x
    var contentObj struct {
326
7x
        Text *string `json:"text,omitempty"`
327
7x
    }
328
7x
    err = json.Unmarshal(contentBytes, &contentObj)
329
7x
    if err != nil {
330
        return nil, contextutils.WrapError(err, "failed to unmarshal message content")
331
    }
332
7x
    message.Content = contentObj
333
7x

334
7x
    return &message, nil
335
}
336

337
// GetConversationMessages retrieves all messages for a conversation
338
4x
func (s *ConversationService) GetConversationMessages(ctx context.Context, conversationID string, userID uint) ([]api.ChatMessage, error) {
339
4x
    // First verify the conversation belongs to the user
340
4x
    var ownerID uint
341
4x
    err := s.db.QueryRowContext(ctx, "SELECT user_id FROM ai_conversations WHERE id = $1", conversationID).Scan(&ownerID)
342
4x
    if err != nil {
343
        if err == sql.ErrNoRows {
344
            return nil, contextutils.ErrorWithContextf("conversation not found")
345
        }
346
        return nil, contextutils.WrapError(err, "failed to verify conversation ownership")
347
    }
348

349
4x
    if ownerID != userID {
350
        return nil, contextutils.ErrorWithContextf("conversation not found")
351
    }
352

353
4x
    query := `
354
4x
        SELECT id, conversation_id, question_id, role, answer_json, bookmarked, created_at, updated_at
355
4x
        FROM ai_chat_messages
356
4x
        WHERE conversation_id = $1
357
4x
        ORDER BY created_at ASC`
358
4x

359
4x
    rows, err := s.db.QueryContext(ctx, query, conversationID)
360
4x
    if err != nil {
361
        return nil, contextutils.WrapError(err, "failed to query messages")
362
    }
363
4x
    defer func() { _ = rows.Close() }()
364

365
4x
    var messages []api.ChatMessage
366
4x
    for rows.Next() {
367
5x
        var msg api.ChatMessage
368
5x
        var questionIDPtr *int
369
5x

370
5x
        var answerBytes []byte
371
5x
        err := rows.Scan(
372
5x
            &msg.Id,
373
5x
            &msg.ConversationId,
374
5x
            &questionIDPtr,
375
5x
            &msg.Role,
376
5x
            &answerBytes,
377
5x
            &msg.Bookmarked,
378
5x
            &msg.CreatedAt,
379
5x
            &msg.UpdatedAt,
380
5x
        )
381
5x
        if err != nil {
382
            return nil, contextutils.WrapError(err, "failed to scan message")
383
        }
384

385
        // Content is now stored as an object, unmarshal accordingly
386
5x
        var contentObj struct {
387
5x
            Text *string `json:"text,omitempty"`
388
5x
        }
389
5x
        err = json.Unmarshal(answerBytes, &contentObj)
390
5x
        if err != nil {
391
            return nil, contextutils.WrapError(err, "failed to unmarshal message content")
392
        }
393
5x
        msg.Content = contentObj
394
5x
        if err != nil {
395
            return nil, contextutils.WrapError(err, "failed to unmarshal message content")
396
        }
397

398
5x
        if questionIDPtr != nil {
399
            msg.QuestionId = questionIDPtr
400
        }
401

402
5x
        messages = append(messages, msg)
403
    }
404

405
4x
    if err := rows.Err(); err != nil {
406
        return nil, contextutils.WrapError(err, "error iterating messages")
407
    }
408

409
4x
    return messages, nil
410
}
411

412
// ToggleMessageBookmark toggles the bookmark status of a message
413
func (s *ConversationService) ToggleMessageBookmark(ctx context.Context, conversationID, messageID string, userID uint) (bool, error) {
414
    // First verify the conversation belongs to the user
415
    var ownerID uint
416
    err := s.db.QueryRowContext(ctx, "SELECT user_id FROM ai_conversations WHERE id = $1", conversationID).Scan(&ownerID)
417
    if err != nil {
418
        if err == sql.ErrNoRows {
419
            return false, contextutils.ErrorWithContextf("conversation not found")
420
        }
421
        return false, contextutils.WrapError(err, "failed to verify conversation ownership")
422
    }
423

424
    if ownerID != userID {
425
        return false, contextutils.ErrorWithContextf("conversation not found")
426
    }
427

428
    // Get current bookmark status and toggle it
429
    var currentBookmarked bool
430
    err = s.db.QueryRowContext(ctx,
431
        "SELECT bookmarked FROM ai_chat_messages WHERE id = $1 AND conversation_id = $2",
432
        messageID, conversationID).Scan(&currentBookmarked)
433
    if err != nil {
434
        if err == sql.ErrNoRows {
435
            return false, contextutils.ErrorWithContextf("message not found")
436
        }
437
        return false, contextutils.WrapError(err, "failed to get message bookmark status")
438
    }
439

440
    newBookmarked := !currentBookmarked
441

442
    // Update the bookmark status
443
    query := `UPDATE ai_chat_messages SET bookmarked = $1, updated_at = $2 WHERE id = $3 AND conversation_id = $4`
444
    _, err = s.db.ExecContext(ctx, query, newBookmarked, time.Now(), messageID, conversationID)
445
    if err != nil {
446
        return false, contextutils.WrapError(err, "failed to update message bookmark status")
447
    }
448

449
    return newBookmarked, nil
450
}
451

452
// SearchMessages searches across all messages for a user
453
2x
func (s *ConversationService) SearchMessages(ctx context.Context, userID uint, query string, limit, offset int) ([]api.ChatMessage, int, error) {
454
2x
    // Clean and prepare the search query
455
2x
    searchQuery := strings.TrimSpace(query)
456
2x
    if searchQuery == "" {
457
        return nil, 0, contextutils.ErrorWithContextf("search query cannot be empty")
458
    }
459

460
    // Search in the answer_json column (which contains the message content as JSON string)
461
    // We need to search within the JSON string value, so we search for the pattern within quotes
462
2x
    searchTerm := fmt.Sprintf("%%%s%%", strings.ToLower(searchQuery))
463
2x

464
2x
    // Get total count of matching messages
465
2x
    countQuery := `
466
2x
        SELECT COUNT(*)
467
2x
        FROM ai_chat_messages m
468
2x
        JOIN ai_conversations c ON m.conversation_id = c.id
469
2x
        WHERE c.user_id = $1 AND LOWER(m.answer_json::text) LIKE $2`
470
2x

471
2x
    var total int
472
2x
    err := s.db.QueryRowContext(ctx, countQuery, userID, searchTerm).Scan(&total)
473
2x
    if err != nil {
474
        return nil, 0, contextutils.WrapError(err, "failed to count search results")
475
    }
476

477
    // Get messages with conversation titles
478
2x
    querySQL := `
479
2x
        SELECT m.id, m.conversation_id, m.question_id, m.role, m.answer_json::text, m.bookmarked, m.created_at, m.updated_at, c.title
480
2x
        FROM ai_chat_messages m
481
2x
        JOIN ai_conversations c ON m.conversation_id = c.id
482
2x
        WHERE c.user_id = $1 AND LOWER(m.answer_json::text) LIKE $2
483
2x
        ORDER BY m.created_at DESC
484
2x
        LIMIT $3 OFFSET $4`
485
2x

486
2x
    rows, err := s.db.QueryContext(ctx, querySQL, userID, searchTerm, limit, offset)
487
2x
    if err != nil {
488
        return nil, 0, contextutils.WrapError(err, "failed to search messages")
489
    }
490
2x
    defer func() { _ = rows.Close() }()
491

492
2x
    var messages []api.ChatMessage
493
2x
    for rows.Next() {
494
3x
        var msg api.ChatMessage
495
3x
        var questionIDPtr *int
496
3x
        var conversationTitle string
497
3x

498
3x
        var answerBytes []byte
499
3x
        err := rows.Scan(
500
3x
            &msg.Id,
501
3x
            &msg.ConversationId,
502
3x
            &questionIDPtr,
503
3x
            &msg.Role,
504
3x
            &answerBytes,
505
3x
            &msg.Bookmarked,
506
3x
            &msg.CreatedAt,
507
3x
            &msg.UpdatedAt,
508
3x
            &conversationTitle,
509
3x
        )
510
3x
        if err != nil {
511
            return nil, 0, contextutils.WrapError(err, "failed to scan search result")
512
        }
513

514
        // Content is now stored as an object, unmarshal accordingly
515
3x
        var contentObj struct {
516
3x
            Text *string `json:"text,omitempty"`
517
3x
        }
518
3x
        err = json.Unmarshal(answerBytes, &contentObj)
519
3x
        if err != nil {
520
            return nil, 0, contextutils.WrapError(err, "failed to unmarshal message content")
521
        }
522
3x
        msg.Content = contentObj
523
3x

524
3x
        if questionIDPtr != nil {
525
            msg.QuestionId = questionIDPtr
526
        }
527

528
        // Content is retrieved directly as text using ->> operator
529

530
        // Set conversation title for search results
531
3x
        msg.ConversationTitle = &conversationTitle
532
3x

533
3x
        messages = append(messages, msg)
534
    }
535

536
2x
    if err := rows.Err(); err != nil {
537
        return nil, 0, contextutils.WrapError(err, "error iterating search results")
538
    }
539

540
2x
    return messages, total, nil
541
}
542

543
// SearchConversations searches across all conversations for a user
544
func (s *ConversationService) SearchConversations(ctx context.Context, userID uint, query string, limit, offset int) ([]api.Conversation, int, error) {
545
    // Clean and prepare the search query
546
    searchQuery := strings.TrimSpace(query)
547
    if searchQuery == "" {
548
        return nil, 0, contextutils.ErrorWithContextf("search query cannot be empty")
549
    }
550

551
    // Search in both conversation titles and message content
552
    searchTerm := fmt.Sprintf("%%%s%%", strings.ToLower(searchQuery))
553

554
    // Get total count of matching conversations
555
    countQuery := `
556
        SELECT COUNT(DISTINCT c.id)
557
        FROM ai_conversations c
558
        LEFT JOIN ai_chat_messages m ON c.id = m.conversation_id
559
        WHERE c.user_id = $1
560
        AND (LOWER(c.title) LIKE $2 OR LOWER(m.answer_json::text) LIKE $2)`
561

562
    var total int
563
    err := s.db.QueryRowContext(ctx, countQuery, userID, searchTerm).Scan(&total)
564
    if err != nil {
565
        return nil, 0, contextutils.WrapError(err, "failed to count search results")
566
    }
567

568
    // Get conversations with their latest message info
569
    querySQL := `
570
        SELECT DISTINCT c.id, c.title, c.created_at, c.updated_at,
571
               (SELECT COUNT(*) FROM ai_chat_messages WHERE conversation_id = c.id) as message_count,
572
               (SELECT answer_json::text FROM ai_chat_messages WHERE conversation_id = c.id ORDER BY created_at ASC LIMIT 1) as first_message,
573
               (SELECT answer_json::text FROM ai_chat_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message
574
        FROM ai_conversations c
575
        LEFT JOIN ai_chat_messages m ON c.id = m.conversation_id
576
        WHERE c.user_id = $1
577
        AND (LOWER(c.title) LIKE $2 OR LOWER(m.answer_json::text) LIKE $2)
578
        ORDER BY c.updated_at DESC
579
        LIMIT $3 OFFSET $4`
580

581
    rows, err := s.db.QueryContext(ctx, querySQL, userID, searchTerm, limit, offset)
582
    if err != nil {
583
        return nil, 0, contextutils.WrapError(err, "failed to search conversations")
584
    }
585
    defer func() { _ = rows.Close() }()
586

587
    conversations := []api.Conversation{}
588
    for rows.Next() {
589
        var conv api.Conversation
590
        var firstMessagePtr, lastMessagePtr *string
591
        var messageCount int
592

593
        err := rows.Scan(
594
            &conv.Id,
595
            &conv.Title,
596
            &conv.CreatedAt,
597
            &conv.UpdatedAt,
598
            &messageCount,
599
            &firstMessagePtr,
600
            &lastMessagePtr,
601
        )
602
        if err != nil {
603
            return nil, 0, contextutils.WrapError(err, "failed to scan search result")
604
        }
605

606
        // Set the preview message to the last message if available, otherwise the first message
607
        previewMessage := ""
608
        if lastMessagePtr != nil {
609
            previewMessage = *lastMessagePtr
610
        } else if firstMessagePtr != nil {
611
            previewMessage = *firstMessagePtr
612
        }
613

614
        // For search results, we need to create a minimal content object
615
        contentObj := struct {
616
            Text *string `json:"text,omitempty"`
617
        }{
618
            Text: &previewMessage,
619
        }
620

621
        // Add preview_message field for frontend compatibility
622
        conv.Messages = &[]api.ChatMessage{
623
            {
624
                Content: contentObj,
625
            },
626
        }
627

628
        conversations = append(conversations, conv)
629
    }
630

631
    if err := rows.Err(); err != nil {
632
        return nil, 0, contextutils.WrapError(err, "error iterating search results")
633
    }
634

635
    return conversations, total, nil
636
}
637

638
// GetBookmarkedMessages retrieves all bookmarked messages for a user
639
func (s *ConversationService) GetBookmarkedMessages(ctx context.Context, userID uint, query string, limit, offset int) ([]api.ChatMessage, int, error) {
640
    // Clean and prepare the search query if provided
641
    searchTerm := "%"
642
    if query != "" {
643
        searchQuery := strings.TrimSpace(query)
644
        searchTerm = fmt.Sprintf("%%%s%%", strings.ToLower(searchQuery))
645
    }
646

647
    // Get total count of bookmarked messages
648
    countQuery := `
649
        SELECT COUNT(*)
650
        FROM ai_chat_messages m
651
        JOIN ai_conversations c ON m.conversation_id = c.id
652
        WHERE c.user_id = $1 AND m.bookmarked = true AND LOWER(m.answer_json::text) LIKE $2`
653

654
    var total int
655
    err := s.db.QueryRowContext(ctx, countQuery, userID, searchTerm).Scan(&total)
656
    if err != nil {
657
        return nil, 0, contextutils.WrapError(err, "failed to count bookmarked messages")
658
    }
659

660
    // Get bookmarked messages with conversation titles
661
    querySQL := `
662
        SELECT m.id, m.conversation_id, m.question_id, m.role, m.answer_json::text, m.bookmarked, m.created_at, m.updated_at, c.title
663
        FROM ai_chat_messages m
664
        JOIN ai_conversations c ON m.conversation_id = c.id
665
        WHERE c.user_id = $1 AND m.bookmarked = true AND LOWER(m.answer_json::text) LIKE $2
666
        ORDER BY m.created_at DESC
667
        LIMIT $3 OFFSET $4`
668

669
    rows, err := s.db.QueryContext(ctx, querySQL, userID, searchTerm, limit, offset)
670
    if err != nil {
671
        return nil, 0, contextutils.WrapError(err, "failed to get bookmarked messages")
672
    }
673
    defer func() { _ = rows.Close() }()
674

675
    messages := []api.ChatMessage{}
676
    for rows.Next() {
677
        var msg api.ChatMessage
678
        var questionIDPtr *int
679
        var conversationTitle string
680

681
        var answerBytes []byte
682
        err := rows.Scan(
683
            &msg.Id,
684
            &msg.ConversationId,
685
            &questionIDPtr,
686
            &msg.Role,
687
            &answerBytes,
688
            &msg.Bookmarked,
689
            &msg.CreatedAt,
690
            &msg.UpdatedAt,
691
            &conversationTitle,
692
        )
693
        if err != nil {
694
            return nil, 0, contextutils.WrapError(err, "failed to scan bookmarked message")
695
        }
696

697
        // Content is stored as an object, unmarshal accordingly
698
        var contentObj struct {
699
            Text *string `json:"text,omitempty"`
700
        }
701
        err = json.Unmarshal(answerBytes, &contentObj)
702
        if err != nil {
703
            return nil, 0, contextutils.WrapError(err, "failed to unmarshal message content")
704
        }
705
        msg.Content = contentObj
706

707
        if questionIDPtr != nil {
708
            msg.QuestionId = questionIDPtr
709
        }
710

711
        // Set conversation title for display
712
        msg.ConversationTitle = &conversationTitle
713

714
        messages = append(messages, msg)
715
    }
716

717
    if err := rows.Err(); err != nil {
718
        return nil, 0, contextutils.WrapError(err, "error iterating bookmarked messages")
719
    }
720

721
    return messages, total, nil
722
}
723


			
quizapp internal services worker_service.go
62.2%
Statements
265/426
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "fmt"
7
    "time"
8

9
    "quizapp/internal/api"
10
    "quizapp/internal/models"
11
    "quizapp/internal/observability"
12
    contextutils "quizapp/internal/utils"
13

14
    "go.opentelemetry.io/otel"
15
    "go.opentelemetry.io/otel/attribute"
16
    "go.opentelemetry.io/otel/codes"
17
    "go.opentelemetry.io/otel/trace"
18
)
19

20
// DailyQuestionServiceInterface defines the interface for daily question operations
21
type DailyQuestionServiceInterface interface {
22
    AssignDailyQuestions(ctx context.Context, userID int, date time.Time) error
23
    RegenerateDailyQuestions(ctx context.Context, userID int, date time.Time) error
24
    GetDailyQuestions(ctx context.Context, userID int, date time.Time) ([]*models.DailyQuestionAssignmentWithQuestion, error)
25
    MarkQuestionCompleted(ctx context.Context, userID, questionID int, date time.Time) error
26
    ResetQuestionCompleted(ctx context.Context, userID, questionID int, date time.Time) error
27
    SubmitDailyQuestionAnswer(ctx context.Context, userID, questionID int, date time.Time, userAnswerIndex int) (*api.AnswerResponse, error)
28
    GetAvailableDates(ctx context.Context, userID int) ([]time.Time, error)
29
    GetDailyProgress(ctx context.Context, userID int, date time.Time) (*models.DailyProgress, error)
30
    GetDailyQuestionsCount(ctx context.Context, userID int, date time.Time) (int, error)
31
    GetCompletedDailyQuestionsCount(ctx context.Context, userID int, date time.Time) (int, error)
32
    GetQuestionHistory(ctx context.Context, userID, questionID, days int) ([]*models.DailyQuestionHistory, error)
33
}
34

35
// DailyQuestionService implements daily question assignment and management
36
type DailyQuestionService struct {
37
    db              *sql.DB
38
    logger          *observability.Logger
39
    questionService QuestionServiceInterface
40
    learningService LearningServiceInterface
41
}
42

43
// NewDailyQuestionService creates a new DailyQuestionService instance
44
15x
func NewDailyQuestionService(db *sql.DB, logger *observability.Logger, questionService QuestionServiceInterface, learningService LearningServiceInterface) *DailyQuestionService {
45
15x
    return &DailyQuestionService{
46
15x
        db:              db,
47
15x
        logger:          logger,
48
15x
        questionService: questionService,
49
15x
        learningService: learningService,
50
15x
    }
51
15x
}
52

53
// AssignDailyQuestions assigns 10 random questions to a user for a specific date
54
33x
func (s *DailyQuestionService) AssignDailyQuestions(ctx context.Context, userID int, date time.Time) (err error) {
55
33x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "AssignDailyQuestions",
56
33x
        trace.WithAttributes(
57
33x
            attribute.Int("user.id", userID),
58
33x
            attribute.String("date", date.Format("2006-01-02")),
59
33x
        ),
60
33x
    )
61
33x
    defer func() {
62
33x
        if err != nil {
63
2x
            span.RecordError(err, trace.WithStackTrace(true))
64
2x
            span.SetStatus(codes.Error, err.Error())
65
2x
        }
66
33x
        span.End()
67
    }()
68

69
    // Get user to determine language and level preferences
70
33x
    user, err := s.getUserByID(ctx, userID)
71
33x
    if err != nil {
72
        span.RecordError(err)
73
        return contextutils.WrapError(err, "failed to get user")
74
    }
75

76
33x
    if user == nil {
77
        return contextutils.ErrorWithContextf("user not found: %d", userID)
78
    }
79
33x
    span.SetAttributes(attribute.String("user.name", user.Username))
80
33x

81
33x
    language := user.PreferredLanguage.String
82
33x
    level := user.CurrentLevel.String
83
33x

84
33x
    if language == "" || level == "" {
85
1x
        return contextutils.ErrorWithContextf("user missing language or level preferences")
86
1x
    }
87

88
    // Get user's daily goal from learning preferences
89
32x
    prefs, perr := s.learningService.GetUserLearningPreferences(ctx, userID)
90
32x
    if perr != nil {
91
        span.RecordError(perr)
92
        return contextutils.WrapError(perr, "failed to get user learning preferences")
93
    }
94
32x
    goal := 10
95
32x
    if prefs != nil && prefs.DailyGoal > 0 {
96
32x
        goal = prefs.DailyGoal
97
32x
    }
98

99
    // Check existing assignments and only fill missing slots up to the user's goal
100
32x
    existingCount, err := s.GetDailyQuestionsCount(ctx, userID, date)
101
32x
    if err != nil {
102
        span.RecordError(err)
103
        return contextutils.WrapError(err, "failed to check existing assignments")
104
    }
105
32x
    if existingCount >= goal {
106
3x
        // s.logger.Info(ctx, "Daily questions already assigned for date", map[string]interface{}{
107
3x
        //     "user_id": userID,
108
3x
        //     "date":    date.Format("2006-01-02"),
109
3x
        //     "count":   existingCount,
110
3x
        //     "goal":    goal,
111
3x
        // })
112
3x
        return nil // Already assigned
113
3x
    }
114

115
    // Request more candidates than strictly needed to allow filtering out already-assigned questions
116
29x
    buffer := 10 // request this many extra candidates beyond the user's goal
117
29x
    reqLimit := goal + buffer
118
29x

119
29x
    // Get adaptive questions using an expanded limit so we can filter and still meet goal
120
29x
    questionsWithStats, err := s.questionService.GetAdaptiveQuestionsForDaily(ctx, userID, language, level, reqLimit)
121
29x
    if err != nil {
122
        span.RecordError(err)
123
        return contextutils.WrapError(err, "failed to get adaptive questions for assignment")
124
    }
125

126
29x
    if len(questionsWithStats) == 0 {
127
1x
        // Gather diagnostics to explain why no questions were available
128
1x
        var candidateIDs []int
129
1x
        candidateCount := 0
130
1x
        totalMatching := 0
131
1x
        if s.questionService != nil {
132
1x
            if candidates, qerr := s.questionService.GetAdaptiveQuestionsForDaily(ctx, userID, language, level, 50); qerr == nil && candidates != nil {
133
1x
                candidateCount = len(candidates)
134
1x
                for i, q := range candidates {
135
                    if i >= 10 {
136
                        break
137
                    }
138
                    if q != nil {
139
                        candidateIDs = append(candidateIDs, q.ID)
140
                    }
141
                }
142
            }
143
1x
            if _, total, terr := s.questionService.GetAllQuestionsPaginated(ctx, 1, 1, "", "", "", language, level, nil); terr == nil {
144
1x
                totalMatching = total
145
1x
            }
146
        }
147

148
1x
        return &NoQuestionsAvailableError{
149
1x
            Language:       language,
150
1x
            Level:          level,
151
1x
            CandidateIDs:   candidateIDs,
152
1x
            CandidateCount: candidateCount,
153
1x
            TotalMatching:  totalMatching,
154
1x
        }
155
    }
156

157
    // Filter out questions that are already assigned for this user/date to
158
    // avoid selecting already-inserted questions and thus underfilling the goal.
159
28x
    assignedIDs := make(map[int]bool)
160
28x
    rows, qerr := s.db.QueryContext(ctx, `SELECT question_id FROM daily_question_assignments WHERE user_id = $1 AND assignment_date = $2`, userID, date)
161
28x
    if qerr == nil {
162
28x
        defer func() {
163
28x
            if closeErr := rows.Close(); closeErr != nil {
164
                s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": closeErr.Error()})
165
            }
166
        }()
167
28x
        for rows.Next() {
168
53x
            var qid int
169
53x
            if err := rows.Scan(&qid); err == nil {
170
53x
                assignedIDs[qid] = true
171
53x
            }
172
        }
173
    }
174

175
    // Convert QuestionWithStats to Question for assignment, skipping already-assigned
176
28x
    var questions []models.Question
177
28x
    for _, qws := range questionsWithStats {
178
568x
        if qws == nil || qws.Question == nil {
179
            continue
180
        }
181
568x
        if assignedIDs[qws.ID] {
182
41x
            // already assigned for this date, skip
183
41x
            continue
184
        }
185
527x
        questions = append(questions, *qws.Question)
186
    }
187

188
    // Only insert up to the number of slots we need to fill
189
28x
    toAssign := goal - existingCount
190
28x
    if toAssign < 0 {
191
        toAssign = 0
192
    }
193
28x
    if len(questions) > toAssign {
194
21x
        questions = questions[:toAssign]
195
21x
    }
196

197
    // Begin transaction
198
28x
    tx, err := s.db.BeginTx(ctx, nil)
199
28x
    if err != nil {
200
        span.RecordError(err)
201
        return contextutils.WrapError(err, "failed to begin transaction")
202
    }
203
28x
    defer func() {
204
28x
        if err != nil {
205
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
206
                s.logger.Error(ctx, "Failed to rollback transaction", rollbackErr, map[string]interface{}{
207
                    "user_id": userID,
208
                    "date":    date.Format("2006-01-02"),
209
                })
210
            }
211
        }
212
    }()
213

214
    // Insert assignments (idempotent via conditional INSERT to avoid duplicate rows)
215
28x
    insertQuery := `
216
28x
        INSERT INTO daily_question_assignments (user_id, question_id, assignment_date, created_at)
217
28x
        SELECT $1, $2, $3, $4
218
28x
        WHERE NOT EXISTS (
219
28x
            SELECT 1 FROM daily_question_assignments WHERE user_id = $1 AND question_id = $2 AND assignment_date = $3
220
28x
        )
221
28x
    `
222
28x

223
28x
    for _, question := range questions {
224
322x
        _, err = tx.ExecContext(ctx, insertQuery, userID, question.ID, date, time.Now())
225
322x
        if err != nil {
226
            span.RecordError(err)
227
            return contextutils.WrapError(err, "failed to insert assignment")
228
        }
229
    }
230

231
    // Commit transaction
232
28x
    err = tx.Commit()
233
28x
    if err != nil {
234
        span.RecordError(err)
235
        return contextutils.WrapError(err, "failed to commit transaction")
236
    }
237

238
28x
    s.logger.Info(ctx, "Daily questions assigned successfully", map[string]interface{}{
239
28x
        "user_id": userID,
240
28x
        "date":    date.Format("2006-01-02"),
241
28x
        "count":   len(questions),
242
28x
    })
243
28x

244
28x
    return nil
245
}
246

247
// RegenerateDailyQuestions clears existing daily question assignments and creates new ones for a user and date
248
3x
func (s *DailyQuestionService) RegenerateDailyQuestions(ctx context.Context, userID int, date time.Time) (err error) {
249
3x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "RegenerateDailyQuestions",
250
3x
        trace.WithAttributes(
251
3x
            attribute.Int("user.id", userID),
252
3x
            attribute.String("date", date.Format("2006-01-02")),
253
3x
        ),
254
3x
    )
255
3x
    defer func() {
256
3x
        if err != nil {
257
            span.RecordError(err, trace.WithStackTrace(true))
258
            span.SetStatus(codes.Error, err.Error())
259
        }
260
3x
        span.End()
261
    }()
262

263
    // Get user to determine language and level preferences
264
3x
    user, err := s.getUserByID(ctx, userID)
265
3x
    if err != nil {
266
        span.RecordError(err)
267
        return contextutils.WrapError(err, "failed to get user")
268
    }
269

270
3x
    if user == nil {
271
        return contextutils.ErrorWithContextf("user not found: %d", userID)
272
    }
273

274
3x
    language := user.PreferredLanguage.String
275
3x
    level := user.CurrentLevel.String
276
3x

277
3x
    if language == "" || level == "" {
278
        return contextutils.ErrorWithContextf("user missing language or level preferences")
279
    }
280

281
    // Get user's daily goal from learning preferences
282
3x
    prefs, perr := s.learningService.GetUserLearningPreferences(ctx, userID)
283
3x
    if perr != nil {
284
        span.RecordError(perr)
285
        return contextutils.WrapError(perr, "failed to get user learning preferences")
286
    }
287
3x
    goal := 10
288
3x
    if prefs != nil && prefs.DailyGoal > 0 {
289
3x
        goal = prefs.DailyGoal
290
3x
    }
291

292
    // Request more candidates than strictly needed to allow filtering out already-assigned questions
293
3x
    buffer := 10 // request this many extra candidates beyond the user's goal
294
3x
    reqLimit := goal + buffer
295
3x

296
3x
    // Get adaptive questions using an expanded limit so we can filter and still meet goal
297
3x
    questionsWithStats, err := s.questionService.GetAdaptiveQuestionsForDaily(ctx, userID, language, level, reqLimit)
298
3x
    if err != nil {
299
        span.RecordError(err)
300
        return contextutils.WrapError(err, "failed to get adaptive questions for assignment")
301
    }
302

303
3x
    if len(questionsWithStats) == 0 {
304
        // Gather diagnostics to explain why no questions were available
305
        var candidateIDs []int
306
        candidateCount := 0
307
        totalMatching := 0
308
        if s.questionService != nil {
309
            if candidates, qerr := s.questionService.GetAdaptiveQuestionsForDaily(ctx, userID, language, level, 50); qerr == nil && candidates != nil {
310
                candidateCount = len(candidates)
311
                for i, q := range candidates {
312
                    if i >= 10 {
313
                        break
314
                    }
315
                    if q != nil {
316
                        candidateIDs = append(candidateIDs, q.ID)
317
                    }
318
                }
319
            }
320
            if _, total, terr := s.questionService.GetAllQuestionsPaginated(ctx, 1, 1, "", "", "", language, level, nil); terr == nil {
321
                totalMatching = total
322
            }
323
        }
324

325
        return &NoQuestionsAvailableError{
326
            Language:       language,
327
            Level:          level,
328
            CandidateIDs:   candidateIDs,
329
            CandidateCount: candidateCount,
330
            TotalMatching:  totalMatching,
331
        }
332
    }
333

334
    // Convert QuestionWithStats to Question for assignment
335
3x
    var questions []models.Question
336
3x
    for _, qws := range questionsWithStats {
337
47x
        questions = append(questions, *qws.Question)
338
47x
    }
339

340
    // Begin transaction
341
3x
    tx, err := s.db.BeginTx(ctx, nil)
342
3x
    if err != nil {
343
        span.RecordError(err)
344
        return contextutils.WrapError(err, "failed to begin transaction")
345
    }
346
3x
    defer func() {
347
3x
        if err != nil {
348
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
349
                s.logger.Error(ctx, "Failed to rollback transaction", rollbackErr, map[string]interface{}{
350
                    "user_id": userID,
351
                    "date":    date.Format("2006-01-02"),
352
                })
353
            }
354
        }
355
    }()
356

357
    // First, delete existing assignments for this user and date
358
3x
    deleteQuery := `DELETE FROM daily_question_assignments WHERE user_id = $1 AND assignment_date = $2`
359
3x
    _, err = tx.ExecContext(ctx, deleteQuery, userID, date)
360
3x
    if err != nil {
361
        span.RecordError(err)
362
        return contextutils.WrapError(err, "failed to delete existing assignments")
363
    }
364

365
    // Insert new assignments
366
3x
    insertQuery := `
367
3x
        INSERT INTO daily_question_assignments (user_id, question_id, assignment_date, created_at)
368
3x
        VALUES ($1, $2, $3, $4)
369
3x
    `
370
3x

371
3x
    stmt, err := tx.PrepareContext(ctx, insertQuery)
372
3x
    if err != nil {
373
        span.RecordError(err)
374
        return contextutils.WrapError(err, "failed to prepare statement")
375
    }
376
3x
    defer func() {
377
3x
        if closeErr := stmt.Close(); closeErr != nil {
378
            s.logger.Error(ctx, "Failed to close statement", closeErr, map[string]interface{}{
379
                "user_id": userID,
380
                "date":    date.Format("2006-01-02"),
381
            })
382
        }
383
    }()
384

385
    // Only assign up to the goal amount
386
3x
    assignedCount := 0
387
3x
    for _, question := range questions {
388
35x
        if assignedCount >= goal {
389
3x
            break
390
        }
391
32x
        _, err = stmt.ExecContext(ctx, userID, question.ID, date, time.Now())
392
32x
        if err != nil {
393
            span.RecordError(err)
394
            return contextutils.WrapError(err, "failed to insert assignment")
395
        }
396
32x
        assignedCount++
397
    }
398

399
    // Commit transaction
400
3x
    err = tx.Commit()
401
3x
    if err != nil {
402
        span.RecordError(err)
403
        return contextutils.WrapError(err, "failed to commit transaction")
404
    }
405

406
3x
    s.logger.Info(ctx, "Daily questions regenerated successfully", map[string]interface{}{
407
3x
        "user_id": userID,
408
3x
        "date":    date.Format("2006-01-02"),
409
3x
        "count":   len(questions),
410
3x
    })
411
3x

412
3x
    return nil
413
}
414

415
// GetDailyQuestions retrieves all daily questions for a user on a specific date
416
17x
func (s *DailyQuestionService) GetDailyQuestions(ctx context.Context, userID int, date time.Time) (result0 []*models.DailyQuestionAssignmentWithQuestion, err error) {
417
17x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetDailyQuestions",
418
17x
        trace.WithAttributes(
419
17x
            attribute.Int("user.id", userID),
420
17x
            attribute.String("date", date.Format("2006-01-02")),
421
17x
        ),
422
17x
    )
423
17x
    defer func() {
424
17x
        if err != nil {
425
            span.RecordError(err, trace.WithStackTrace(true))
426
            span.SetStatus(codes.Error, err.Error())
427
        }
428
17x
        span.End()
429
    }()
430

431
17x
    query := `
432
17x
        SELECT dqa.id, dqa.user_id, dqa.question_id, dqa.assignment_date,
433
17x
               dqa.is_completed, dqa.completed_at, dqa.created_at,
434
17x
               dqa.user_answer_index, dqa.submitted_at,
435
17x
               q.id, q.type, q.language, q.level, q.difficulty_score, q.content,
436
17x
               q.correct_answer, q.explanation, q.created_at, q.status,
437
17x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario,
438
17x
               q.style_modifier, q.difficulty_modifier, q.time_context,
439
17x
               -- Daily shown count per user: how many times this user has seen this question in Daily across all dates
440
17x
               (SELECT COUNT(*) FROM daily_question_assignments dqa_all WHERE dqa_all.question_id = dqa.question_id AND dqa_all.user_id = dqa.user_id) AS daily_shown_count,
441
17x
               -- Per-user correctness stats across all time
442
17x
               COALESCE((SELECT COUNT(*) FROM user_responses ur WHERE ur.user_id = dqa.user_id AND ur.question_id = dqa.question_id), 0) AS user_total_responses,
443
17x
               COALESCE((SELECT COUNT(*) FROM user_responses ur WHERE ur.user_id = dqa.user_id AND ur.question_id = dqa.question_id AND ur.is_correct = TRUE), 0) AS user_correct_count,
444
17x
               COALESCE((SELECT COUNT(*) FROM user_responses ur WHERE ur.user_id = dqa.user_id AND ur.question_id = dqa.question_id AND ur.is_correct = FALSE), 0) AS user_incorrect_count
445
17x
        FROM daily_question_assignments dqa
446
17x
        JOIN questions q ON dqa.question_id = q.id
447
17x
        WHERE dqa.user_id = $1 AND dqa.assignment_date = $2
448
17x
        ORDER BY dqa.created_at ASC
449
17x
    `
450
17x

451
17x
    rows, err := s.db.QueryContext(ctx, query, userID, date)
452
17x
    if err != nil {
453
        span.RecordError(err)
454
        return nil, contextutils.WrapError(err, "failed to query daily questions")
455
    }
456
17x
    defer func() {
457
17x
        if closeErr := rows.Close(); closeErr != nil {
458
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{
459
                "user_id": userID,
460
                "date":    date.Format("2006-01-02"),
461
            })
462
        }
463
    }()
464

465
17x
    var assignments []*models.DailyQuestionAssignmentWithQuestion
466
17x
    for rows.Next() {
467
170x
        var assignment models.DailyQuestionAssignmentWithQuestion
468
170x
        var question models.Question
469
170x
        var contentJSON string
470
170x

471
170x
        err := rows.Scan(
472
170x
            &assignment.ID, &assignment.UserID, &assignment.QuestionID, &assignment.AssignmentDate,
473
170x
            &assignment.IsCompleted, &assignment.CompletedAt, &assignment.CreatedAt,
474
170x
            &assignment.UserAnswerIndex, &assignment.SubmittedAt,
475
170x
            &question.ID, &question.Type, &question.Language, &question.Level, &question.DifficultyScore,
476
170x
            &contentJSON, &question.CorrectAnswer, &question.Explanation, &question.CreatedAt, &question.Status,
477
170x
            &question.TopicCategory, &question.GrammarFocus, &question.VocabularyDomain, &question.Scenario,
478
170x
            &question.StyleModifier, &question.DifficultyModifier, &question.TimeContext,
479
170x
            &assignment.DailyShownCount,
480
170x
            &assignment.UserTotalResponses,
481
170x
            &assignment.UserCorrectCount,
482
170x
            &assignment.UserIncorrectCount,
483
170x
        )
484
170x
        if err != nil {
485
            s.logger.Error(ctx, "Failed to scan daily question assignment", err, map[string]interface{}{
486
                "user_id": userID,
487
                "date":    date.Format("2006-01-02"),
488
            })
489
            continue
490
        }
491

492
        // Unmarshal the JSON content
493
170x
        if err := question.UnmarshalContentFromJSON(contentJSON); err != nil {
494
            s.logger.Error(ctx, "Failed to unmarshal question content", err, map[string]interface{}{
495
                "user_id": userID,
496
                "date":    date.Format("2006-01-02"),
497
                "content": contentJSON,
498
            })
499
            continue
500
        }
501

502
170x
        assignment.Question = &question
503
170x
        assignments = append(assignments, &assignment)
504
    }
505

506
17x
    if err = rows.Err(); err != nil {
507
        span.RecordError(err)
508
        return nil, contextutils.WrapError(err, "error iterating over rows")
509
    }
510

511
17x
    return assignments, nil
512
}
513

514
// MarkQuestionCompleted marks a daily question as completed
515
5x
func (s *DailyQuestionService) MarkQuestionCompleted(ctx context.Context, userID, questionID int, date time.Time) (err error) {
516
5x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "MarkQuestionCompleted",
517
5x
        trace.WithAttributes(
518
5x
            attribute.Int("user.id", userID),
519
5x
            attribute.Int("question.id", questionID),
520
5x
            attribute.String("date", date.Format("2006-01-02")),
521
5x
        ),
522
5x
    )
523
5x
    defer func() {
524
5x
        if err != nil {
525
            span.RecordError(err, trace.WithStackTrace(true))
526
            span.SetStatus(codes.Error, err.Error())
527
        }
528
5x
        span.End()
529
    }()
530

531
5x
    query := `
532
5x
        UPDATE daily_question_assignments
533
5x
        SET is_completed = true, completed_at = $1
534
5x
        WHERE user_id = $2 AND question_id = $3 AND assignment_date = $4
535
5x
    `
536
5x

537
5x
    result, err := s.db.ExecContext(ctx, query, time.Now(), userID, questionID, date)
538
5x
    if err != nil {
539
        span.RecordError(err)
540
        return contextutils.WrapError(err, "failed to mark question as completed")
541
    }
542

543
5x
    rowsAffected, err := result.RowsAffected()
544
5x
    if err != nil {
545
        span.RecordError(err)
546
        return contextutils.WrapError(err, "failed to get rows affected")
547
    }
548

549
5x
    if rowsAffected == 0 {
550
        return contextutils.ErrAssignmentNotFound
551
    }
552

553
5x
    s.logger.Info(ctx, "Question marked as completed", map[string]interface{}{
554
5x
        "user_id":     userID,
555
5x
        "question_id": questionID,
556
5x
        "date":        date.Format("2006-01-02"),
557
5x
    })
558
5x

559
5x
    return nil
560
}
561

562
// ResetQuestionCompleted resets a daily question to not completed
563
1x
func (s *DailyQuestionService) ResetQuestionCompleted(ctx context.Context, userID, questionID int, date time.Time) (err error) {
564
1x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "ResetQuestionCompleted",
565
1x
        trace.WithAttributes(
566
1x
            attribute.Int("user.id", userID),
567
1x
            attribute.Int("question.id", questionID),
568
1x
            attribute.String("date", date.Format("2006-01-02")),
569
1x
        ),
570
1x
    )
571
1x
    defer func() {
572
1x
        if err != nil {
573
            span.RecordError(err, trace.WithStackTrace(true))
574
            span.SetStatus(codes.Error, err.Error())
575
        }
576
1x
        span.End()
577
    }()
578

579
1x
    query := `
580
1x
        UPDATE daily_question_assignments
581
1x
        SET is_completed = false, completed_at = NULL, user_answer_index = NULL, submitted_at = NULL
582
1x
        WHERE user_id = $1 AND question_id = $2 AND assignment_date = $3
583
1x
    `
584
1x

585
1x
    result, err := s.db.ExecContext(ctx, query, userID, questionID, date)
586
1x
    if err != nil {
587
        span.RecordError(err)
588
        return contextutils.WrapError(err, "failed to reset question completion")
589
    }
590

591
1x
    rowsAffected, err := result.RowsAffected()
592
1x
    if err != nil {
593
        span.RecordError(err)
594
        return contextutils.WrapError(err, "failed to get rows affected")
595
    }
596

597
1x
    if rowsAffected == 0 {
598
        return contextutils.ErrAssignmentNotFound
599
    }
600

601
1x
    s.logger.Info(ctx, "Question reset to not completed", map[string]interface{}{
602
1x
        "user_id":     userID,
603
1x
        "question_id": questionID,
604
1x
        "date":        date.Format("2006-01-02"),
605
1x
    })
606
1x

607
1x
    return nil
608
}
609

610
// GetAvailableDates retrieves all dates for which a user has daily question assignments
611
1x
func (s *DailyQuestionService) GetAvailableDates(ctx context.Context, userID int) (result0 []time.Time, err error) {
612
1x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetAvailableDates",
613
1x
        trace.WithAttributes(
614
1x
            attribute.Int("user.id", userID),
615
1x
        ),
616
1x
    )
617
1x
    defer func() {
618
1x
        if err != nil {
619
            span.RecordError(err, trace.WithStackTrace(true))
620
            span.SetStatus(codes.Error, err.Error())
621
        }
622
1x
        span.End()
623
    }()
624

625
1x
    query := `
626
1x
        SELECT DISTINCT assignment_date
627
1x
        FROM daily_question_assignments
628
1x
        WHERE user_id = $1
629
1x
        ORDER BY assignment_date DESC
630
1x
    `
631
1x

632
1x
    rows, err := s.db.QueryContext(ctx, query, userID)
633
1x
    if err != nil {
634
        span.RecordError(err)
635
        return nil, contextutils.WrapError(err, "failed to query available dates")
636
    }
637
1x
    defer func() {
638
1x
        if closeErr := rows.Close(); closeErr != nil {
639
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{
640
                "user_id": userID,
641
            })
642
        }
643
    }()
644

645
1x
    var dates []time.Time
646
1x
    for rows.Next() {
647
3x
        var date time.Time
648
3x
        err := rows.Scan(&date)
649
3x
        if err != nil {
650
            s.logger.Error(ctx, "Failed to scan date", err, map[string]interface{}{
651
                "user_id": userID,
652
            })
653
            continue
654
        }
655
3x
        dates = append(dates, date)
656
    }
657

658
1x
    if err = rows.Err(); err != nil {
659
        span.RecordError(err)
660
        return nil, contextutils.WrapError(err, "error iterating over rows")
661
    }
662

663
1x
    return dates, nil
664
}
665

666
// GetDailyProgress retrieves the progress for a specific date
667
2x
func (s *DailyQuestionService) GetDailyProgress(ctx context.Context, userID int, date time.Time) (result0 *models.DailyProgress, err error) {
668
2x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetDailyProgress",
669
2x
        trace.WithAttributes(
670
2x
            attribute.Int("user.id", userID),
671
2x
            attribute.String("date", date.Format("2006-01-02")),
672
2x
        ),
673
2x
    )
674
2x
    defer func() {
675
2x
        if err != nil {
676
            span.RecordError(err, trace.WithStackTrace(true))
677
            span.SetStatus(codes.Error, err.Error())
678
        }
679
2x
        span.End()
680
    }()
681

682
2x
    query := `
683
2x
        SELECT
684
2x
            COUNT(*) as total,
685
2x
            COUNT(CASE WHEN is_completed = true THEN 1 END) as completed
686
2x
        FROM daily_question_assignments
687
2x
        WHERE user_id = $1 AND assignment_date = $2
688
2x
    `
689
2x

690
2x
    var total, completed int
691
2x
    err = s.db.QueryRowContext(ctx, query, userID, date).Scan(&total, &completed)
692
2x
    if err != nil {
693
        return nil, contextutils.WrapError(err, "failed to get daily progress")
694
    }
695

696
2x
    progress := &models.DailyProgress{
697
2x
        Date:      date,
698
2x
        Completed: completed,
699
2x
        Total:     total,
700
2x
    }
701
2x

702
2x
    return progress, nil
703
}
704

705
// GetDailyQuestionsCount retrieves the total number of questions assigned for a date
706
32x
func (s *DailyQuestionService) GetDailyQuestionsCount(ctx context.Context, userID int, date time.Time) (result0 int, err error) {
707
32x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetDailyQuestionsCount",
708
32x
        trace.WithAttributes(
709
32x
            attribute.Int("user.id", userID),
710
32x
            attribute.String("date", date.Format("2006-01-02")),
711
32x
        ),
712
32x
    )
713
32x
    defer func() {
714
32x
        if err != nil {
715
            span.RecordError(err, trace.WithStackTrace(true))
716
            span.SetStatus(codes.Error, err.Error())
717
        }
718
32x
        span.End()
719
    }()
720

721
32x
    query := `
722
32x
        SELECT COUNT(*)
723
32x
        FROM daily_question_assignments
724
32x
        WHERE user_id = $1 AND assignment_date = $2
725
32x
    `
726
32x

727
32x
    var count int
728
32x
    err = s.db.QueryRowContext(ctx, query, userID, date).Scan(&count)
729
32x
    if err != nil {
730
        return 0, contextutils.WrapError(err, "failed to get daily questions count")
731
    }
732

733
32x
    return count, nil
734
}
735

736
// GetCompletedDailyQuestionsCount retrieves the number of completed questions for a date
737
func (s *DailyQuestionService) GetCompletedDailyQuestionsCount(ctx context.Context, userID int, date time.Time) (result0 int, err error) {
738
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetCompletedDailyQuestionsCount",
739
        trace.WithAttributes(
740
            attribute.Int("user.id", userID),
741
            attribute.String("date", date.Format("2006-01-02")),
742
        ),
743
    )
744
    defer func() {
745
        if err != nil {
746
            span.RecordError(err, trace.WithStackTrace(true))
747
            span.SetStatus(codes.Error, err.Error())
748
        }
749
        span.End()
750
    }()
751

752
    query := `
753
        SELECT COUNT(*)
754
        FROM daily_question_assignments
755
        WHERE user_id = $1 AND assignment_date = $2 AND is_completed = true
756
    `
757

758
    var count int
759
    err = s.db.QueryRowContext(ctx, query, userID, date).Scan(&count)
760
    if err != nil {
761
        return 0, contextutils.WrapError(err, "failed to get completed daily questions count")
762
    }
763

764
    return count, nil
765
}
766

767
// GetQuestionHistory retrieves the history of a specific question for a user over a given number of days
768
2x
func (s *DailyQuestionService) GetQuestionHistory(ctx context.Context, userID, questionID, days int) (result0 []*models.DailyQuestionHistory, err error) {
769
2x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetQuestionHistory",
770
2x
        trace.WithAttributes(
771
2x
            attribute.Int("user.id", userID),
772
2x
            attribute.Int("question.id", questionID),
773
2x
            attribute.Int("days", days),
774
2x
        ),
775
2x
    )
776
2x
    defer func() {
777
2x
        if err != nil {
778
2x
            span.RecordError(err, trace.WithStackTrace(true))
779
2x
            span.SetStatus(codes.Error, err.Error())
780
2x
        }
781
2x
        span.End()
782
    }()
783

784
2x
    if days <= 0 {
785
2x
        return nil, contextutils.ErrorWithContextf("days must be positive")
786
2x
    }
787

788
    query := `
789
        SELECT dqa.assignment_date, dqa.is_completed, dqa.submitted_at,
790
               ur.is_correct
791
        FROM daily_question_assignments dqa
792
        LEFT JOIN daily_assignment_responses dar ON dar.assignment_id = dqa.id
793
        LEFT JOIN user_responses ur ON ur.id = dar.user_response_id
794
        WHERE dqa.user_id = $1 AND dqa.question_id = $2
795
        AND dqa.assignment_date >= NOW() - INTERVAL '` + fmt.Sprintf("%d days", days) + `'
796
        AND dqa.assignment_date <= CURRENT_DATE + INTERVAL '1 day'
797
        ORDER BY dqa.assignment_date ASC
798
    `
799

800
    rows, err := s.db.QueryContext(ctx, query, userID, questionID)
801
    if err != nil {
802
        span.RecordError(err)
803
        return nil, contextutils.WrapError(err, "failed to query question history")
804
    }
805
    defer func() {
806
        if closeErr := rows.Close(); closeErr != nil {
807
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{
808
                "user_id":     userID,
809
                "question_id": questionID,
810
                "days":        days,
811
            })
812
        }
813
    }()
814

815
    var history []*models.DailyQuestionHistory
816
    for rows.Next() {
817
        var historyEntry models.DailyQuestionHistory
818
        var isCorrect sql.NullBool
819
        err := rows.Scan(
820
            &historyEntry.AssignmentDate,
821
            &historyEntry.IsCompleted,
822
            &historyEntry.SubmittedAt,
823
            &isCorrect,
824
        )
825
        if err != nil {
826
            s.logger.Error(ctx, "Failed to scan question history entry", err, map[string]interface{}{
827
                "user_id":         userID,
828
                "question_id":     questionID,
829
                "assignment_date": historyEntry.AssignmentDate,
830
            })
831
            continue
832
        }
833
        if isCorrect.Valid {
834
            historyEntry.IsCorrect = &isCorrect.Bool
835
        } else {
836
            historyEntry.IsCorrect = nil
837
        }
838
        history = append(history, &historyEntry)
839
    }
840

841
    if err = rows.Err(); err != nil {
842
        span.RecordError(err)
843
        return nil, contextutils.WrapError(err, "error iterating over rows")
844
    }
845

846
    return history, nil
847
}
848

849
// getUserByID is a helper method to get user information
850
36x
func (s *DailyQuestionService) getUserByID(ctx context.Context, userID int) (*models.User, error) {
851
36x
    query := `
852
36x
        SELECT id, username, email, timezone, password_hash, last_active,
853
36x
               preferred_language, current_level, ai_provider, ai_model,
854
36x
               ai_enabled, ai_api_key, created_at, updated_at
855
36x
        FROM users
856
36x
        WHERE id = $1
857
36x
    `
858
36x

859
36x
    var user models.User
860
36x
    err := s.db.QueryRowContext(ctx, query, userID).Scan(
861
36x
        &user.ID, &user.Username, &user.Email, &user.Timezone, &user.PasswordHash,
862
36x
        &user.LastActive, &user.PreferredLanguage, &user.CurrentLevel, &user.AIProvider,
863
36x
        &user.AIModel, &user.AIEnabled, &user.AIAPIKey, &user.CreatedAt, &user.UpdatedAt,
864
36x
    )
865
36x
    if err != nil {
866
        if err == sql.ErrNoRows {
867
            return nil, nil
868
        }
869
        return nil, err
870
    }
871

872
36x
    return &user, nil
873
}
874

875
// SubmitDailyQuestionAnswer submits an answer for a daily question and marks it as completed
876
1x
func (s *DailyQuestionService) SubmitDailyQuestionAnswer(ctx context.Context, userID, questionID int, date time.Time, userAnswerIndex int) (result *api.AnswerResponse, err error) {
877
1x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "SubmitDailyQuestionAnswer",
878
1x
        trace.WithAttributes(
879
1x
            attribute.Int("user.id", userID),
880
1x
            attribute.Int("question.id", questionID),
881
1x
            attribute.String("date", date.Format("2006-01-02")),
882
1x
            attribute.Int("user_answer_index", userAnswerIndex),
883
1x
        ),
884
1x
    )
885
1x
    defer func() {
886
1x
        if err != nil {
887
            span.RecordError(err, trace.WithStackTrace(true))
888
            span.SetStatus(codes.Error, err.Error())
889
        }
890
1x
        span.End()
891
    }()
892

893
1x
    s.logger.Info(ctx, "SubmitDailyQuestionAnswer started", map[string]interface{}{
894
1x
        "user_id":           userID,
895
1x
        "question_id":       questionID,
896
1x
        "date":              date.Format("2006-01-02"),
897
1x
        "user_answer_index": userAnswerIndex,
898
1x
    })
899
1x

900
1x
    // Check if the question is already answered
901
1x
    s.logger.Info(ctx, "Checking if question is already answered", map[string]interface{}{
902
1x
        "user_id":     userID,
903
1x
        "question_id": questionID,
904
1x
        "date":        date.Format("2006-01-02"),
905
1x
    })
906
1x

907
1x
    query := `
908
1x
        SELECT id, is_completed, user_answer_index, submitted_at
909
1x
        FROM daily_question_assignments
910
1x
        WHERE user_id = $1 AND question_id = $2 AND assignment_date = $3
911
1x
    `
912
1x

913
1x
    var assignmentID int
914
1x
    var isCompleted bool
915
1x
    var existingUserAnswerIndex *int
916
1x
    var existingSubmittedAt *time.Time
917
1x

918
1x
    err = s.db.QueryRowContext(ctx, query, userID, questionID, date).Scan(
919
1x
        &assignmentID, &isCompleted, &existingUserAnswerIndex, &existingSubmittedAt,
920
1x
    )
921
1x
    if err != nil {
922
        if err == sql.ErrNoRows {
923
            return nil, contextutils.ErrAssignmentNotFound
924
        }
925
        return nil, contextutils.WrapError(err, "failed to check question assignment")
926
    }
927

928
    // Check if already answered
929
1x
    if isCompleted && existingUserAnswerIndex != nil && existingSubmittedAt != nil {
930
        return nil, contextutils.ErrQuestionAlreadyAnswered
931
    }
932

933
    // Get the question details to validate answer and get correct answer
934
1x
    question, err := s.questionService.GetQuestionByID(ctx, questionID)
935
1x
    if err != nil {
936
        return nil, contextutils.WrapError(err, "failed to get question details")
937
    }
938

939
1x
    if question == nil {
940
        return nil, contextutils.ErrQuestionNotFound
941
    }
942

943
    // Extract options from content map
944
1x
    contentMap := question.Content
945
1x
    s.logger.Info(ctx, "Question content debug", map[string]interface{}{
946
1x
        "question_id": questionID,
947
1x
        "content_map": contentMap,
948
1x
    })
949
1x

950
1x
    optionsInterface, ok := contentMap["options"]
951
1x
    if !ok {
952
        s.logger.Error(ctx, "Question content missing options", nil, map[string]interface{}{
953
            "question_id": questionID,
954
            "content_map": contentMap,
955
        })
956
        return nil, contextutils.ErrorWithContextf("question content missing options")
957
    }
958

959
1x
    options, ok := optionsInterface.([]interface{})
960
1x
    if !ok {
961
        s.logger.Error(ctx, "Invalid options format", nil, map[string]interface{}{
962
            "question_id":       questionID,
963
            "options_interface": optionsInterface,
964
            "options_type":      fmt.Sprintf("%T", optionsInterface),
965
        })
966
        return nil, contextutils.ErrorWithContextf("invalid options format")
967
    }
968

969
    // Validate user answer index
970
1x
    if userAnswerIndex < 0 || userAnswerIndex >= len(options) {
971
        return nil, contextutils.ErrInvalidAnswerIndex
972
    }
973

974
    // Check if answer is correct
975
1x
    isCorrect := question.CorrectAnswer == userAnswerIndex
976
1x

977
1x
    // Begin transaction
978
1x
    tx, err := s.db.BeginTx(ctx, nil)
979
1x
    if err != nil {
980
        return nil, contextutils.WrapError(err, "failed to begin transaction")
981
    }
982

983
1x
    defer func() {
984
1x
        if err != nil {
985
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
986
                s.logger.Error(ctx, "Failed to rollback transaction", rollbackErr, map[string]interface{}{
987
                    "error": rollbackErr.Error(),
988
                })
989
            }
990
        }
991
    }()
992

993
    // Update the assignment with the user's answer and mark as completed
994
1x
    updateQuery := `
995
1x
        UPDATE daily_question_assignments
996
1x
        SET is_completed = true, completed_at = NOW(), user_answer_index = $1, submitted_at = NOW()
997
1x
        WHERE id = $2
998
1x
    `
999
1x

1000
1x
    _, err = tx.ExecContext(ctx, updateQuery, userAnswerIndex, assignmentID)
1001
1x
    if err != nil {
1002
        return nil, contextutils.WrapError(err, "failed to update assignment")
1003
    }
1004

1005
    // Commit transaction
1006
1x
    err = tx.Commit()
1007
1x
    if err != nil {
1008
        return nil, contextutils.WrapError(err, "failed to commit transaction")
1009
    }
1010

1011
    // Record canonical user response via learningService so history queries see is_correct
1012
    // Use RecordAnswerWithPriorityReturningID to obtain user_responses.id so we can link it to the assignment.
1013
1x
    if s.learningService != nil {
1014
1x
        // record synchronously so we have the response id for mapping
1015
1x
        respID, recErr := s.learningService.RecordAnswerWithPriorityReturningID(ctx, userID, questionID, userAnswerIndex, isCorrect, 0)
1016
1x
        if recErr != nil {
1017
            s.logger.Error(ctx, "Failed to record user response for daily answer", recErr, map[string]interface{}{
1018
                "user_id":           userID,
1019
                "question_id":       questionID,
1020
                "user_answer_index": userAnswerIndex,
1021
            })
1022
        } else {
1023
1x
            // Insert mapping to daily_assignment_responses synchronously so tests that run immediately can observe it
1024
1x
            _, mapErr := s.db.ExecContext(ctx, `
1025
1x
                INSERT INTO daily_assignment_responses (assignment_id, user_response_id, created_at)
1026
1x
                VALUES ($1, $2, NOW())
1027
1x
                ON CONFLICT (assignment_id) DO UPDATE SET user_response_id = EXCLUDED.user_response_id, created_at = EXCLUDED.created_at
1028
1x
            `, assignmentID, respID)
1029
1x
            if mapErr != nil {
1030
                // Log but don't fail user's request
1031
                s.logger.Error(ctx, "Failed to insert daily_assignment_responses mapping", mapErr, map[string]interface{}{
1032
                    "assignment_id":    assignmentID,
1033
                    "user_response_id": respID,
1034
                })
1035
            }
1036

1037
            // If the answer was correct, remove future assignments for this question within the avoid window
1038
1x
            if isCorrect {
1039
1x
                // Determine avoidDays via questionService if possible; default to 7
1040
1x
                avoidDays := 7
1041
1x
                switch qs := s.questionService.(type) {
1042
1x
                case interface{ getDailyRepeatAvoidDays() int }:
1043
1x
                    avoidDays = qs.getDailyRepeatAvoidDays()
1044
                default:
1045
                    // leave default
1046
                }
1047

1048
1x
                startDate := date.AddDate(0, 0, 1)
1049
1x
                endDate := date.AddDate(0, 0, avoidDays)
1050
1x

1051
1x
                deleteQuery := `DELETE FROM daily_question_assignments WHERE user_id = $1 AND question_id = $2 AND assignment_date >= $3 AND assignment_date <= $4`
1052
1x
                if _, delErr := s.db.ExecContext(ctx, deleteQuery, userID, questionID, startDate, endDate); delErr != nil {
1053
                    s.logger.Error(ctx, "Failed to delete future daily assignments", delErr, map[string]interface{}{
1054
                        "user_id":     userID,
1055
                        "question_id": questionID,
1056
                        "start":       startDate,
1057
                        "end":         endDate,
1058
                    })
1059
                } else {
1060
1x
                    // Future assignments removed successfully; worker will top up missing slots on its next run
1061
1x
                    s.logger.Info(ctx, "Deleted future daily assignments for question; worker will refill dates as needed", map[string]interface{}{
1062
1x
                        "user_id":     userID,
1063
1x
                        "question_id": questionID,
1064
1x
                        "start":       startDate,
1065
1x
                        "end":         endDate,
1066
1x
                    })
1067
1x
                }
1068
            }
1069
        }
1070
    }
1071

1072
    // Build response
1073
1x
    userAnswer := options[userAnswerIndex].(string)
1074
1x
    response := &api.AnswerResponse{
1075
1x
        UserAnswerIndex: &userAnswerIndex,
1076
1x
        UserAnswer:      &userAnswer,
1077
1x
        IsCorrect:       &isCorrect,
1078
1x
    }
1079
1x

1080
1x
    // Add correct answer and explanation if available
1081
1x
    response.CorrectAnswerIndex = &question.CorrectAnswer
1082
1x
    if question.Explanation != "" {
1083
1x
        response.Explanation = &question.Explanation
1084
1x
    }
1085

1086
1x
    s.logger.Info(ctx, "Daily question answer submitted", map[string]interface{}{
1087
1x
        "user_id":           userID,
1088
1x
        "question_id":       questionID,
1089
1x
        "date":              date.Format("2006-01-02"),
1090
1x
        "user_answer_index": userAnswerIndex,
1091
1x
        "is_correct":        isCorrect,
1092
1x
    })
1093
1x

1094
1x
    return response, nil
1095
}
1096


			
quizapp internal services worker_service.go
90.9%
Statements
10/11
1
// Package services provides business logic services for the quiz application.
2
package services
3

4
import (
5
    "context"
6
    "database/sql"
7

8
    "quizapp/internal/config"
9
    "quizapp/internal/observability"
10
    "quizapp/internal/services/mailer"
11
)
12

13
// CreateEmailService creates an appropriate email service based on configuration
14
// If the application is running in test mode, it returns a TestEmailService
15
// Otherwise, it returns the regular EmailService
16
2x
func CreateEmailService(cfg *config.Config, logger *observability.Logger) mailer.Mailer {
17
2x
    if cfg.IsTest {
18
1x
        logger.Info(context.Background(), "Using test email service", map[string]interface{}{
19
1x
            "test_mode": true,
20
1x
        })
21
1x
        return NewTestEmailService(cfg, logger)
22
1x
    }
23

24
1x
    return NewEmailService(cfg, logger)
25
}
26

27
// CreateEmailServiceWithDB creates an appropriate email service with database connection based on configuration
28
// If the application is running in test mode, it returns a TestEmailService
29
// Otherwise, it returns the regular EmailService
30
2x
func CreateEmailServiceWithDB(cfg *config.Config, logger *observability.Logger, db *sql.DB) mailer.Mailer {
31
2x
    if cfg.IsTest {
32
1x
        logger.Info(context.Background(), "Using test email service with DB", map[string]interface{}{
33
1x
            "test_mode": true,
34
1x
        })
35
1x
        return NewTestEmailServiceWithDB(cfg, logger, db)
36
1x
    }
37

38
1x
    if db == nil {
39
1x
        logger.Error(context.Background(), "Database connection is nil, cannot create EmailService", nil, map[string]interface{}{
40
1x
            "error": "nil_database_connection",
41
1x
        })
42
1x
        panic("EmailService requires a non-nil database connection")
43
    }
44

45
    return NewEmailServiceWithDB(cfg, logger, db)
46
}
47


			
quizapp internal services worker_service.go
34.5%
Statements
49/142
1
// Package services provides business logic services for the quiz application.
2
package services
3

4
import (
5
    "context"
6
    "database/sql"
7
    "fmt"
8
    "html/template"
9
    "strings"
10
    "time"
11

12
    "quizapp/internal/config"
13
    "quizapp/internal/models"
14
    "quizapp/internal/observability"
15
    serviceinterfaces "quizapp/internal/serviceinterfaces"
16
    contextutils "quizapp/internal/utils"
17

18
    "go.opentelemetry.io/otel"
19
    "go.opentelemetry.io/otel/attribute"
20
    "go.opentelemetry.io/otel/trace"
21
    "gopkg.in/mail.v2"
22
)
23

24
// EmailService implements the interfaces.EmailService interface using gomail
25
type EmailService struct {
26
    cfg    *config.Config
27
    logger *observability.Logger
28
    dialer *mail.Dialer
29
    db     *sql.DB
30
}
31

32
// EmailServiceInterface defines the interface for email functionality
33
type EmailServiceInterface = serviceinterfaces.EmailService
34

35
// Ensure EmailService implements the EmailServiceInterface
36
var _ serviceinterfaces.EmailService = (*EmailService)(nil)
37

38
// NewEmailService creates a new EmailService instance
39
14x
func NewEmailService(cfg *config.Config, logger *observability.Logger) *EmailService {
40
14x
    var dialer *mail.Dialer
41
14x
    if cfg.Email.Enabled && cfg.Email.SMTP.Host != "" {
42
7x
        dialer = mail.NewDialer(
43
7x
            cfg.Email.SMTP.Host,
44
7x
            cfg.Email.SMTP.Port,
45
7x
            cfg.Email.SMTP.Username,
46
7x
            cfg.Email.SMTP.Password,
47
7x
        )
48
7x
    }
49

50
14x
    return &EmailService{
51
14x
        cfg:    cfg,
52
14x
        logger: logger,
53
14x
        dialer: dialer,
54
14x
    }
55
}
56

57
// NewEmailServiceWithDB creates a new EmailService instance with database connection
58
3x
func NewEmailServiceWithDB(cfg *config.Config, logger *observability.Logger, db *sql.DB) *EmailService {
59
3x
    if db == nil {
60
1x
        panic("EmailService requires a non-nil database connection")
61
    }
62

63
1x
    var dialer *mail.Dialer
64
1x
    if cfg.Email.Enabled && cfg.Email.SMTP.Host != "" {
65
1x
        dialer = mail.NewDialer(
66
1x
            cfg.Email.SMTP.Host,
67
1x
            cfg.Email.SMTP.Port,
68
1x
            cfg.Email.SMTP.Username,
69
1x
            cfg.Email.SMTP.Password,
70
1x
        )
71
1x
    }
72

73
1x
    return &EmailService{
74
1x
        cfg:    cfg,
75
1x
        logger: logger,
76
1x
        dialer: dialer,
77
1x
        db:     db,
78
1x
    }
79
}
80

81
// SendDailyReminder sends a daily reminder email to a user
82
2x
func (e *EmailService) SendDailyReminder(ctx context.Context, user *models.User) (err error) {
83
2x
    ctx, span := otel.Tracer("email-service").Start(ctx, "SendDailyReminder",
84
2x
        trace.WithAttributes(
85
2x
            attribute.Int("user.id", user.ID),
86
2x
            attribute.String("user.email", user.Email.String),
87
2x
        ),
88
2x
    )
89
2x
    defer observability.FinishSpan(span, &err)
90
2x

91
2x
    if !e.IsEnabled() {
92
1x
        e.logger.Info(ctx, "Email disabled, skipping daily reminder", map[string]interface{}{
93
1x
            "user_id": user.ID,
94
1x
            "email":   user.Email.String,
95
1x
        })
96
1x
        return nil
97
1x
    }
98

99
1x
    if !user.Email.Valid || user.Email.String == "" {
100
1x
        e.logger.Warn(ctx, "User has no email address, skipping daily reminder", map[string]interface{}{
101
1x
            "user_id": user.ID,
102
1x
        })
103
1x
        return nil
104
1x
    }
105

106
    // Determine daily goal from DB
107
    dailyGoal := 10
108
    var dg sql.NullInt64
109
    if err := e.db.QueryRowContext(ctx, "SELECT daily_goal FROM user_learning_preferences WHERE user_id = $1", user.ID).Scan(&dg); err == nil && dg.Valid {
110
        dailyGoal = int(dg.Int64)
111
    }
112

113
    // Warn if AppBaseURL contains localhost in production (email is enabled, indicating production)
114
    if strings.Contains(e.cfg.Server.AppBaseURL, "localhost") {
115
        e.logger.Warn(ctx, "AppBaseURL contains localhost in production environment - email links will point to localhost", map[string]interface{}{
116
            "app_base_url": e.cfg.Server.AppBaseURL,
117
            "user_id":      user.ID,
118
            "suggestion":   "Set SERVER_APP_BASE_URL environment variable to production URL",
119
        })
120
    }
121

122
    // Generate email data
123
    data := map[string]interface{}{
124
        "Username":       user.Username,
125
        "QuizAppURL":     e.cfg.Server.AppBaseURL, // Frontend app URL for email links
126
        "CurrentDate":    time.Now().Format("January 2, 2006"),
127
        "DailyGoal":      dailyGoal,
128
        "UnsubscribeURL": fmt.Sprintf("%s/settings", e.cfg.Server.AppBaseURL),
129
    }
130

131
    subject := "Time for your daily quiz! ð"
132

133
    err = e.SendEmail(ctx, user.Email.String, subject, "daily_reminder", data)
134
    if err != nil {
135
        return contextutils.WrapError(err, "failed to send daily reminder")
136
    }
137

138
    e.logger.Info(ctx, "Daily reminder sent successfully", map[string]interface{}{
139
        "user_id": user.ID,
140
        "email":   user.Email.String,
141
    })
142

143
    return nil
144
}
145

146
// SendEmail sends a generic email with the given parameters
147
2x
func (e *EmailService) SendEmail(ctx context.Context, to, subject, templateName string, data map[string]interface{}) (err error) {
148
2x
    ctx, span := otel.Tracer("email-service").Start(ctx, "SendEmail",
149
2x
        trace.WithAttributes(
150
2x
            attribute.String("email.to", to),
151
2x
            attribute.String("email.subject", subject),
152
2x
            attribute.String("email.template", templateName),
153
2x
        ),
154
2x
    )
155
2x
    defer observability.FinishSpan(span, &err)
156
2x

157
2x
    if !e.IsEnabled() {
158
2x
        e.logger.Info(ctx, "Email disabled, skipping email send", map[string]interface{}{
159
2x
            "to":       to,
160
2x
            "template": templateName,
161
2x
        })
162
2x
        return nil
163
2x
    }
164

165
    if e.dialer == nil {
166
        return contextutils.ErrorWithContextf("email service not properly configured")
167
    }
168

169
    // Create email message
170
    m := mail.NewMessage()
171
    m.SetHeader("From", fmt.Sprintf("%s <%s>", e.cfg.Email.SMTP.FromName, e.cfg.Email.SMTP.FromAddress))
172
    m.SetHeader("To", to)
173
    m.SetHeader("Subject", subject)
174

175
    // Generate email content from template
176
    content, err := e.generateEmailContent(templateName, data)
177
    if err != nil {
178
        return contextutils.WrapError(err, "failed to generate email content")
179
    }
180

181
    m.SetBody("text/html", content)
182

183
    // Send email
184
    if err = e.dialer.DialAndSend(m); err != nil {
185
        e.logger.Error(ctx, "Failed to send email", err, map[string]interface{}{
186
            "to":       to,
187
            "template": templateName,
188
            "subject":  subject,
189
        })
190
        return contextutils.WrapError(err, "failed to send email")
191
    }
192

193
    e.logger.Info(ctx, "Email sent successfully", map[string]interface{}{
194
        "to":       to,
195
        "template": templateName,
196
        "subject":  subject,
197
    })
198

199
    return nil
200
}
201

202
// RecordSentNotification records a sent notification in the database
203
func (e *EmailService) RecordSentNotification(ctx context.Context, userID int, notificationType, subject, templateName, status, errorMessage string) (err error) {
204
    ctx, span := otel.Tracer("email-service").Start(ctx, "RecordSentNotification",
205
        trace.WithAttributes(
206
            attribute.Int("user.id", userID),
207
            attribute.String("notification.type", notificationType),
208
            attribute.String("notification.status", status),
209
        ),
210
    )
211
    defer observability.FinishSpan(span, &err)
212

213
    if e.db == nil {
214
        e.logger.Error(ctx, "Database connection is nil, cannot record notification", nil, map[string]interface{}{
215
            "user_id":           userID,
216
            "notification_type": notificationType,
217
        })
218
        return contextutils.ErrorWithContextf("EmailService database connection is nil")
219
    }
220

221
    query := `
222
        INSERT INTO sent_notifications (user_id, notification_type, subject, template_name, sent_at, status, error_message)
223
        VALUES ($1, $2, $3, $4, $5, $6, $7)
224
    `
225

226
    _, err = e.db.ExecContext(ctx, query, userID, notificationType, subject, templateName, time.Now(), status, errorMessage)
227
    if err != nil {
228
        e.logger.Error(ctx, "Failed to record sent notification", err, map[string]interface{}{
229
            "user_id":           userID,
230
            "notification_type": notificationType,
231
            "status":            status,
232
        })
233
        return contextutils.WrapError(err, "failed to record sent notification")
234
    }
235

236
    e.logger.Info(ctx, "Recorded sent notification", map[string]interface{}{
237
        "user_id":           userID,
238
        "notification_type": notificationType,
239
        "status":            status,
240
    })
241

242
    return nil
243
}
244

245
// IsEnabled returns whether email functionality is enabled
246
9x
func (e *EmailService) IsEnabled() bool {
247
9x
    return e.cfg.Email.Enabled && e.cfg.Email.SMTP.Host != ""
248
9x
}
249

250
// generateEmailContent generates email content from templates
251
2x
func (e *EmailService) generateEmailContent(templateName string, data map[string]interface{}) (string, error) {
252
2x
    // For now, we'll use a simple template system
253
2x
    // In a real implementation, you might load templates from files or database
254
2x
    switch templateName {
255
1x
    case "daily_reminder":
256
1x
        return e.generateDailyReminderTemplate(data)
257
    case "test_email":
258
        return e.generateTestEmailTemplate(data)
259
    case "word_of_the_day":
260
        return e.generateWordOfTheDayTemplate(data)
261
1x
    default:
262
1x
        return "", contextutils.ErrorWithContextf("unknown template: %s", templateName)
263
    }
264
}
265

266
// generateDailyReminderTemplate generates the daily reminder email template
267
2x
func (e *EmailService) generateDailyReminderTemplate(data map[string]interface{}) (string, error) {
268
2x
    const templateStr = `
269
2x
<!DOCTYPE html>
270
2x
<html>
271
2x
<head>
272
2x
    <meta charset="UTF-8">
273
2x
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
274
2x
    <title>Daily Quiz Reminder</title>
275
2x
    <style>
276
2x
        body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
277
2x
        .container { max-width: 600px; margin: 0 auto; padding: 20px; }
278
2x
        .header { background-color: #4CAF50; color: white; padding: 20px; text-align: center; border-radius: 5px 5px 0 0; }
279
2x
        .content { background-color: #f9f9f9; padding: 20px; }
280
2x
        .button { display: inline-block; background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; margin: 20px 0; }
281
2x
        .footer { background-color: #eee; padding: 15px; text-align: center; font-size: 12px; color: #666; border-radius: 0 0 5px 5px; }
282
2x
    </style>
283
2x
</head>
284
2x
<body>
285
2x
    <div class="container">
286
2x
        <div class="header">
287
2x
            <h1>ð Daily Quiz Reminder</h1>
288
2x
        </div>
289
2x
        <div class="content">
290
2x
            <h2>Hello {{.Username}}!</h2>
291
2x
            <p>It's {{.CurrentDate}} and time for your daily questions!</p>
292
2x
            <p>Your goal today: <strong>{{.DailyGoal}} questions</strong></p>
293
2x
            <p>Keep up the great work and continue improving your language skills!</p>
294
2x
            <div style="text-align: center;">
295
2x
                <a href="{{.QuizAppURL}}/daily" class="button">Start Your Daily Questions</a>
296
2x
            </div>
297
2x
        </div>
298
2x
        <div class="footer">
299
2x
            <p>This email was sent by Quiz App. If you no longer wish to receive these reminders, you can <a href="{{.UnsubscribeURL}}">unsubscribe here</a>.</p>
300
2x
        </div>
301
2x
    </div>
302
2x
</body>
303
2x
</html>`
304
2x

305
2x
    tmpl, err := template.New("daily_reminder").Parse(templateStr)
306
2x
    if err != nil {
307
        return "", contextutils.WrapError(err, "failed to parse template")
308
    }
309

310
2x
    var buf strings.Builder
311
2x
    if err := tmpl.Execute(&buf, data); err != nil {
312
        return "", contextutils.WrapError(err, "failed to execute template")
313
    }
314

315
2x
    return buf.String(), nil
316
}
317

318
// generateTestEmailTemplate generates the test email template
319
2x
func (e *EmailService) generateTestEmailTemplate(data map[string]interface{}) (string, error) {
320
2x
    const templateStr = `
321
2x
<!DOCTYPE html>
322
2x
<html>
323
2x
<head>
324
2x
    <meta charset="UTF-8">
325
2x
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
326
2x
    <title>Test Email</title>
327
2x
    <style>
328
2x
        body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
329
2x
        .container { max-width: 600px; margin: 0 auto; padding: 20px; }
330
2x
        .header { background-color: #2196F3; color: white; padding: 20px; text-align: center; border-radius: 5px 5px 0 0; }
331
2x
        .content { background-color: #f9f9f9; padding: 20px; }
332
2x
        .footer { background-color: #eee; padding: 15px; text-align: center; font-size: 12px; color: #666; border-radius: 0 0 5px 5px; }
333
2x
    </style>
334
2x
</head>
335
2x
<body>
336
2x
    <div class="container">
337
2x
        <div class="header">
338
2x
            <h1>ð Test Email</h1>
339
2x
        </div>
340
2x
        <div class="content">
341
2x
            <h2>Hello {{.Username}}!</h2>
342
2x
            <p>This is a test email to verify that your email settings are working correctly.</p>
343
2x
            <p><strong>Test Time:</strong> {{.TestTime}}</p>
344
2x
            <p><strong>Message:</strong> {{.Message}}</p>
345
2x
            <p>If you received this email, your email configuration is working properly!</p>
346
2x
        </div>
347
2x
        <div class="footer">
348
2x
            <p>This is a test email from Quiz App. No action is required.</p>
349
2x
        </div>
350
2x
    </div>
351
2x
</body>
352
2x
</html>
353
2x
`
354
2x

355
2x
    tmpl, err := template.New("test_email").Parse(templateStr)
356
2x
    if err != nil {
357
        return "", contextutils.WrapError(err, "failed to parse template")
358
    }
359

360
2x
    var buf strings.Builder
361
2x
    if err := tmpl.Execute(&buf, data); err != nil {
362
        return "", contextutils.WrapError(err, "failed to execute template")
363
    }
364

365
2x
    return buf.String(), nil
366
}
367

368
// HasSentWordOfTheDayEmail returns whether a word of the day email has already been sent to the user for the given day
369
3x
func (e *EmailService) HasSentWordOfTheDayEmail(ctx context.Context, userID int, date time.Time) (result bool, err error) {
370
3x
    ctx, span := otel.Tracer("email-service").Start(ctx, "HasSentWordOfTheDayEmail",
371
3x
        trace.WithAttributes(
372
3x
            attribute.Int("user.id", userID),
373
3x
            attribute.String("date", date.Format("2006-01-02")),
374
3x
        ),
375
3x
    )
376
3x
    defer observability.FinishSpan(span, &err)
377
3x

378
3x
    if e.db == nil {
379
        err = contextutils.ErrorWithContextf("EmailService database connection is nil")
380
        span.RecordError(err, trace.WithStackTrace(true))
381
        return false, err
382
    }
383

384
    // Normalize the provided date to the start/end of day in the user's timezone
385
3x
    start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
386
3x
    end := start.Add(24 * time.Hour)
387
3x

388
3x
    const query = `
389
3x
        SELECT EXISTS(
390
3x
            SELECT 1
391
3x
            FROM sent_notifications
392
3x
            WHERE user_id = $1
393
3x
              AND notification_type = 'word_of_the_day'
394
3x
              AND status = 'sent'
395
3x
              AND sent_at >= $2
396
3x
              AND sent_at < $3
397
3x
        )
398
3x
    `
399
3x

400
3x
    err = e.db.QueryRowContext(ctx, query, userID, start.UTC(), end.UTC()).Scan(&result)
401
3x
    if err != nil {
402
        span.RecordError(err, trace.WithStackTrace(true))
403
        return false, contextutils.WrapError(err, "failed to check word of the day email history")
404
    }
405

406
3x
    span.SetAttributes(attribute.Bool("word_of_day.already_sent", result))
407
3x

408
3x
    return result, nil
409
}
410

411
// SendWordOfTheDayEmail sends a word of the day email to a user
412
func (e *EmailService) SendWordOfTheDayEmail(ctx context.Context, userID int, date time.Time, wordOfTheDay *models.WordOfTheDayDisplay) (err error) {
413
    ctx, span := otel.Tracer("email-service").Start(ctx, "SendWordOfTheDayEmail",
414
        trace.WithAttributes(
415
            attribute.Int("email.user_id", userID),
416
            attribute.String("email.date", date.Format("2006-01-02")),
417
        ),
418
    )
419
    defer observability.FinishSpan(span, &err)
420

421
    if !e.IsEnabled() {
422
        e.logger.Info(ctx, "Email disabled, skipping word of the day email", map[string]interface{}{
423
            "user_id": userID,
424
            "date":    date.Format("2006-01-02"),
425
        })
426
        return nil
427
    }
428

429
    // Get user to check email preferences
430
    user, err := e.getUserByID(ctx, userID)
431
    if err != nil {
432
        return contextutils.WrapError(err, "failed to get user")
433
    }
434

435
    if user == nil {
436
        return contextutils.ErrorWithContextf("user not found: %d", userID)
437
    }
438

439
    // Check if user has email disabled for word of the day
440
    if !user.WordOfDayEmailEnabled.Bool {
441
        e.logger.Info(ctx, "User has word of the day emails disabled", map[string]interface{}{
442
            "user_id": userID,
443
        })
444
        return nil
445
    }
446

447
    if !user.Email.Valid || user.Email.String == "" {
448
        return contextutils.ErrorWithContextf("user has no email address")
449
    }
450

451
    // Warn if AppBaseURL contains localhost in production (email is enabled, indicating production)
452
    if strings.Contains(e.cfg.Server.AppBaseURL, "localhost") {
453
        e.logger.Warn(ctx, "AppBaseURL contains localhost in production environment - email links will point to localhost", map[string]interface{}{
454
            "app_base_url": e.cfg.Server.AppBaseURL,
455
            "user_id":      userID,
456
            "suggestion":   "Set SERVER_APP_BASE_URL environment variable to production URL",
457
        })
458
    }
459

460
    // Prepare email data
461
    data := map[string]interface{}{
462
        "Username":       user.Username,
463
        "Word":           wordOfTheDay.Word,
464
        "Translation":    wordOfTheDay.Translation,
465
        "Sentence":       wordOfTheDay.Sentence,
466
        "Date":           date.Format("January 2, 2006"),
467
        "Language":       wordOfTheDay.Language,
468
        "Level":          wordOfTheDay.Level,
469
        "Explanation":    wordOfTheDay.Explanation,
470
        "QuizAppURL":     e.cfg.Server.AppBaseURL,
471
        "UnsubscribeURL": fmt.Sprintf("%s/settings?tab=notifications", e.cfg.Server.AppBaseURL),
472
    }
473

474
    subject := fmt.Sprintf("Word of the Day: %s - %s", wordOfTheDay.Word, date.Format("January 2, 2006"))
475

476
    if err := e.SendEmail(ctx, user.Email.String, subject, "word_of_the_day", data); err != nil {
477
        return contextutils.WrapError(err, "failed to send word of the day email")
478
    }
479

480
    if err := e.RecordSentNotification(ctx, userID, "word_of_the_day", subject, "word_of_the_day", "sent", ""); err != nil {
481
        return contextutils.WrapError(err, "failed to record word of the day notification")
482
    }
483

484
    return nil
485
}
486

487
// generateWordOfTheDayTemplate generates the word of the day email template
488
func (e *EmailService) generateWordOfTheDayTemplate(data map[string]interface{}) (string, error) {
489
    const templateStr = `
490
<!DOCTYPE html>
491
<html>
492
<head>
493
    <meta charset="UTF-8">
494
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
495
    <title>Word of the Day</title>
496
    <style>
497
        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; }
498
        .container { max-width: 600px; margin: 0 auto; padding: 20px; }
499
        .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px 20px; text-align: center; border-radius: 8px 8px 0 0; }
500
        .content { background-color: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; }
501
        .date { color: #667eea; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 15px; }
502
        .word { font-size: 48px; font-weight: bold; color: #1a1a1a; margin-bottom: 15px; line-height: 1.2; }
503
        .translation { font-size: 24px; color: #667eea; margin-bottom: 25px; font-style: italic; }
504
        .sentence { font-size: 18px; line-height: 1.8; color: #555; background: #f7f7f7; padding: 25px; border-radius: 8px; border-left: 4px solid #667eea; margin-bottom: 20px; font-style: italic; }
505
        .explanation { font-size: 15px; color: #666; margin-top: 20px; padding: 20px; background: #fafafa; border-radius: 8px; border-left: 3px solid #764ba2; }
506
        .meta { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px; }
507
        .badge { background: #e0e7ff; color: #667eea; padding: 6px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; }
508
        .button { display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; margin: 20px 0; font-weight: 600; }
509
        .footer { background-color: #f5f5f5; padding: 20px; text-align: center; font-size: 12px; color: #666; border-radius: 0 0 8px 8px; border: 1px solid #e0e0e0; border-top: none; }
510
        .footer a { color: #667eea; text-decoration: none; }
511
    </style>
512
</head>
513
<body>
514
    <div class="container">
515
        <div class="header">
516
            <h1 style="margin: 0; font-size: 28px;">ð Word of the Day</h1>
517
        </div>
518
        <div class="content">
519
            <div class="date">{{.Date}}</div>
520
            <div class="word">{{.Word}}</div>
521
            <div class="translation">{{.Translation}}</div>
522
            {{if .Sentence}}
523
            <div class="sentence">{{.Sentence}}</div>
524
            {{end}}
525
            {{if .Explanation}}
526
            <div class="explanation">{{.Explanation}}</div>
527
            {{end}}
528
            <div class="meta">
529
                {{if .Language}}<span class="badge">{{.Language}}</span>{{end}}
530
                {{if .Level}}<span class="badge">{{.Level}}</span>{{end}}
531
            </div>
532
            <div style="text-align: center; margin-top: 30px;">
533
                <a href="{{.QuizAppURL}}/word-of-day" class="button">View in App</a>
534
            </div>
535
        </div>
536
        <div class="footer">
537
            <p>This email was sent by Quiz App. If you no longer wish to receive word of the day emails, you can <a href="{{.UnsubscribeURL}}">update your preferences here</a>.</p>
538
        </div>
539
    </div>
540
</body>
541
</html>`
542

543
    tmpl, err := template.New("word_of_the_day").Parse(templateStr)
544
    if err != nil {
545
        return "", contextutils.WrapError(err, "failed to parse template")
546
    }
547

548
    var buf strings.Builder
549
    if err := tmpl.Execute(&buf, data); err != nil {
550
        return "", contextutils.WrapError(err, "failed to execute template")
551
    }
552

553
    return buf.String(), nil
554
}
555

556
// getUserByID retrieves a user by ID (helper method)
557
func (e *EmailService) getUserByID(ctx context.Context, userID int) (*models.User, error) {
558
    if e.db == nil {
559
        return nil, contextutils.ErrorWithContextf("database connection not available")
560
    }
561

562
    query := `
563
        SELECT id, username, email, word_of_day_email_enabled
564
        FROM users
565
        WHERE id = $1
566
    `
567

568
    var user models.User
569
    err := e.db.QueryRowContext(ctx, query, userID).Scan(
570
        &user.ID,
571
        &user.Username,
572
        &user.Email,
573
        &user.WordOfDayEmailEnabled,
574
    )
575

576
    if err == sql.ErrNoRows {
577
        return nil, nil
578
    }
579

580
    if err != nil {
581
        return nil, contextutils.WrapError(err, "failed to query user")
582
    }
583

584
    return &user, nil
585
}
586


			
quizapp internal services worker_service.go
0.0%
Statements
0/126
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "encoding/json"
7
    "fmt"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    contextutils "quizapp/internal/utils"
14

15
    "go.opentelemetry.io/otel/attribute"
16
)
17

18
// FeedbackService implements FeedbackServiceInterface for managing feedback reports.
19
type FeedbackService struct {
20
    db     *sql.DB
21
    logger *observability.Logger
22
}
23

24
// NewFeedbackService creates a new FeedbackService instance.
25
func NewFeedbackService(db *sql.DB, logger *observability.Logger) *FeedbackService {
26
    if db == nil {
27
        panic("NewFeedbackService: db is nil")
28
    }
29
    if logger == nil {
30
        panic("NewFeedbackService: logger is nil")
31
    }
32
    return &FeedbackService{db: db, logger: logger}
33
}
34

35
// CreateFeedback inserts a new feedback report.
36
func (s *FeedbackService) CreateFeedback(ctx context.Context, fr *models.FeedbackReport) (result0 *models.FeedbackReport, err error) {
37
    ctx, span := observability.TraceUserFunction(ctx, "create_feedback")
38
    defer observability.FinishSpan(span, &err)
39

40
    contextJSON, err := json.Marshal(fr.ContextData)
41
    if err != nil {
42
        return nil, contextutils.WrapError(err, "failed to marshal context_data")
43
    }
44

45
    query := `INSERT INTO feedback_reports (user_id, feedback_text, feedback_type, context_data, screenshot_data, screenshot_url, status, created_at, updated_at)
46
              VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id, created_at, updated_at`
47
    now := time.Now()
48
    var id int
49
    var createdAt, updatedAt time.Time
50
    err = s.db.QueryRowContext(ctx, query, fr.UserID, fr.FeedbackText, fr.FeedbackType, contextJSON, fr.ScreenshotData, fr.ScreenshotURL, "new", now, now).
51
        Scan(&id, &createdAt, &updatedAt)
52
    if err != nil {
53
        return nil, contextutils.WrapError(err, "failed to insert feedback report")
54
    }
55
    fr.ID = id
56
    fr.Status = "new"
57
    fr.CreatedAt = createdAt
58
    fr.UpdatedAt = updatedAt
59
    return fr, nil
60
}
61

62
// GetFeedbackByID fetches single feedback.
63
func (s *FeedbackService) GetFeedbackByID(ctx context.Context, id int) (result0 *models.FeedbackReport, err error) {
64
    ctx, span := observability.TraceUserFunction(ctx, "get_feedback_by_id")
65
    defer observability.FinishSpan(span, &err)
66

67
    query := `SELECT id, user_id, feedback_text, feedback_type, context_data, screenshot_data, screenshot_url, status, admin_notes, assigned_to_user_id, resolved_at, resolved_by_user_id, created_at, updated_at FROM feedback_reports WHERE id=$1`
68
    row := s.db.QueryRowContext(ctx, query, id)
69
    var fr models.FeedbackReport
70
    var contextJSON []byte
71
    err = row.Scan(&fr.ID, &fr.UserID, &fr.FeedbackText, &fr.FeedbackType, &contextJSON, &fr.ScreenshotData, &fr.ScreenshotURL, &fr.Status, &fr.AdminNotes, &fr.AssignedToUserID, &fr.ResolvedAt, &fr.ResolvedByUserID, &fr.CreatedAt, &fr.UpdatedAt)
72
    if err != nil {
73
        if err == sql.ErrNoRows {
74
            return nil, contextutils.ErrRecordNotFound
75
        }
76
        return nil, contextutils.WrapError(err, "failed to scan feedback")
77
    }
78
    _ = json.Unmarshal(contextJSON, &fr.ContextData)
79
    return &fr, nil
80
}
81

82
// GetFeedbackPaginated returns list of feedback reports with filters.
83
func (s *FeedbackService) GetFeedbackPaginated(ctx context.Context, page, pageSize int, status, feedbackType string, userID *int) (result0 []models.FeedbackReport, result1 int, err error) {
84
    ctx, span := observability.TraceUserFunction(ctx, "get_feedback_paginated")
85
    defer observability.FinishSpan(span, &err)
86

87
    var conditions []string
88
    var args []interface{}
89
    idx := 1
90
    if status != "" {
91
        conditions = append(conditions, fmt.Sprintf("status=$%d", idx))
92
        args = append(args, status)
93
        idx++
94
    }
95
    if feedbackType != "" {
96
        conditions = append(conditions, fmt.Sprintf("feedback_type=$%d", idx))
97
        args = append(args, feedbackType)
98
        idx++
99
    }
100
    if userID != nil {
101
        conditions = append(conditions, fmt.Sprintf("user_id=$%d", idx))
102
        args = append(args, *userID)
103
        idx++
104
    }
105
    where := ""
106
    if len(conditions) > 0 {
107
        where = "WHERE " + strings.Join(conditions, " AND ")
108
    }
109

110
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM feedback_reports %s", where)
111
    var total int
112
    if err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
113
        return nil, 0, contextutils.WrapError(err, "failed to count feedback")
114
    }
115

116
    offset := (page - 1) * pageSize
117
    args = append(args, pageSize, offset)
118
    query := fmt.Sprintf("SELECT id, user_id, feedback_text, feedback_type, context_data, screenshot_data, screenshot_url, status, admin_notes, assigned_to_user_id, resolved_at, resolved_by_user_id, created_at, updated_at FROM feedback_reports %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", where, idx, idx+1)
119

120
    rows, err := s.db.QueryContext(ctx, query, args...)
121
    if err != nil {
122
        return nil, 0, contextutils.WrapError(err, "failed to query feedback list")
123
    }
124
    defer func() {
125
        _ = rows.Close()
126
    }()
127

128
    list := []models.FeedbackReport{}
129
    for rows.Next() {
130
        var fr models.FeedbackReport
131
        var contextJSON []byte
132
        if err := rows.Scan(&fr.ID, &fr.UserID, &fr.FeedbackText, &fr.FeedbackType, &contextJSON, &fr.ScreenshotData, &fr.ScreenshotURL, &fr.Status, &fr.AdminNotes, &fr.AssignedToUserID, &fr.ResolvedAt, &fr.ResolvedByUserID, &fr.CreatedAt, &fr.UpdatedAt); err != nil {
133
            return nil, 0, contextutils.WrapError(err, "scan feedback list")
134
        }
135
        _ = json.Unmarshal(contextJSON, &fr.ContextData)
136
        list = append(list, fr)
137
    }
138
    return list, total, nil
139
}
140

141
// UpdateFeedback allows status/notes assignment updates.
142
func (s *FeedbackService) UpdateFeedback(ctx context.Context, id int, updates map[string]interface{}) (result0 *models.FeedbackReport, err error) {
143
    ctx, span := observability.TraceUserFunction(ctx, "update_feedback", attribute.Int("feedback.id", id))
144
    defer observability.FinishSpan(span, &err)
145

146
    if len(updates) == 0 {
147
        return s.GetFeedbackByID(ctx, id)
148
    }
149

150
    var sets []string
151
    var args []interface{}
152
    idx := 1
153
    for k, v := range updates {
154
        sets = append(sets, fmt.Sprintf("%s=$%d", k, idx))
155
        args = append(args, v)
156
        idx++
157
    }
158
    sets = append(sets, fmt.Sprintf("updated_at=$%d", idx))
159
    args = append(args, time.Now())
160
    args = append(args, id)
161

162
    query := fmt.Sprintf("UPDATE feedback_reports SET %s WHERE id=$%d", strings.Join(sets, ","), idx+1)
163
    if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
164
        return nil, contextutils.WrapError(err, "failed to update feedback")
165
    }
166
    return s.GetFeedbackByID(ctx, id)
167
}
168

169
// DeleteFeedback deletes a single feedback report by ID.
170
func (s *FeedbackService) DeleteFeedback(ctx context.Context, id int) (err error) {
171
    ctx, span := observability.TraceUserFunction(ctx, "delete_feedback", attribute.Int("feedback.id", id))
172
    defer observability.FinishSpan(span, &err)
173

174
    query := `DELETE FROM feedback_reports WHERE id=$1`
175
    result, err := s.db.ExecContext(ctx, query, id)
176
    if err != nil {
177
        return contextutils.WrapError(err, "failed to delete feedback")
178
    }
179

180
    rowsAffected, err := result.RowsAffected()
181
    if err != nil {
182
        return contextutils.WrapError(err, "failed to get rows affected")
183
    }
184

185
    if rowsAffected == 0 {
186
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "feedback with ID %d not found", id)
187
    }
188

189
    return nil
190
}
191

192
// DeleteFeedbackByStatus deletes all feedback reports with a specific status.
193
func (s *FeedbackService) DeleteFeedbackByStatus(ctx context.Context, status string) (result0 int, err error) {
194
    ctx, span := observability.TraceUserFunction(ctx, "delete_feedback_by_status", attribute.String("status", status))
195
    defer observability.FinishSpan(span, &err)
196

197
    query := `DELETE FROM feedback_reports WHERE status=$1`
198
    result, err := s.db.ExecContext(ctx, query, status)
199
    if err != nil {
200
        return 0, contextutils.WrapError(err, "failed to delete feedback by status")
201
    }
202

203
    rowsAffected, err := result.RowsAffected()
204
    if err != nil {
205
        return 0, contextutils.WrapError(err, "failed to get rows affected")
206
    }
207

208
    return int(rowsAffected), nil
209
}
210

211
// DeleteAllFeedback deletes all feedback reports regardless of status.
212
func (s *FeedbackService) DeleteAllFeedback(ctx context.Context) (result0 int, err error) {
213
    ctx, span := observability.TraceUserFunction(ctx, "delete_all_feedback")
214
    defer observability.FinishSpan(span, &err)
215

216
    query := `DELETE FROM feedback_reports`
217
    result, err := s.db.ExecContext(ctx, query)
218
    if err != nil {
219
        return 0, contextutils.WrapError(err, "failed to delete all feedback")
220
    }
221

222
    rowsAffected, err := result.RowsAffected()
223
    if err != nil {
224
        return 0, contextutils.WrapError(err, "failed to get rows affected")
225
    }
226

227
    return int(rowsAffected), nil
228
}
229


			
quizapp internal services worker_service.go
83.3%
Statements
25/30
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "time"
7

8
    "quizapp/internal/models"
9
    "quizapp/internal/observability"
10
    contextutils "quizapp/internal/utils"
11
)
12

13
// GenerationHint represents an active generation hint
14
type GenerationHint struct {
15
    ID             int       `db:"id"`
16
    UserID         int       `db:"user_id"`
17
    Language       string    `db:"language"`
18
    Level          string    `db:"level"`
19
    QuestionType   string    `db:"question_type"`
20
    PriorityWeight int       `db:"priority_weight"`
21
    ExpiresAt      time.Time `db:"expires_at"`
22
    CreatedAt      time.Time `db:"created_at"`
23
}
24

25
// GenerationHintServiceInterface defines the API for managing generation hints
26
type GenerationHintServiceInterface interface {
27
    UpsertHint(ctx context.Context, userID int, language, level string, qType models.QuestionType, ttl time.Duration) error
28
    GetActiveHintsForUser(ctx context.Context, userID int) ([]GenerationHint, error)
29
    ClearHint(ctx context.Context, userID int, language, level string, qType models.QuestionType) error
30
}
31

32
// GenerationHintService implements hint management
33
type GenerationHintService struct {
34
    db     *sql.DB
35
    logger *observability.Logger
36
}
37

38
// NewGenerationHintService constructs a service for managing short-lived per-user
39
// generation hints that nudge the worker to prioritize specific question types
40
// (e.g., reading comprehension) when the user is waiting for generation.
41
1x
func NewGenerationHintService(db *sql.DB, logger *observability.Logger) *GenerationHintService {
42
1x
    return &GenerationHintService{db: db, logger: logger}
43
1x
}
44

45
// UpsertHint creates or refreshes a hint with the given TTL
46
1x
func (s *GenerationHintService) UpsertHint(ctx context.Context, userID int, language, level string, qType models.QuestionType, ttl time.Duration) (err error) {
47
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "upsert_generation_hint")
48
1x
    defer observability.FinishSpan(span, &err)
49
1x

50
1x
    expiresAt := time.Now().Add(ttl)
51
1x
    _, err = s.db.ExecContext(ctx, `
52
1x
        INSERT INTO generation_hints (user_id, language, level, question_type, priority_weight, expires_at)
53
1x
        VALUES ($1, $2, $3, $4, 1, $5)
54
1x
        ON CONFLICT (user_id, language, level, question_type) DO UPDATE SET
55
1x
            priority_weight = generation_hints.priority_weight + 1,
56
1x
            expires_at = EXCLUDED.expires_at,
57
1x
            created_at = generation_hints.created_at
58
1x
    `, userID, language, level, string(qType), expiresAt)
59
1x
    if err != nil {
60
        return contextutils.WrapError(err, "failed to upsert generation hint")
61
    }
62
1x
    return nil
63
}
64

65
// GetActiveHintsForUser returns non-expired hints for the user
66
2x
func (s *GenerationHintService) GetActiveHintsForUser(ctx context.Context, userID int) (result0 []GenerationHint, err error) {
67
2x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_active_generation_hints")
68
2x
    defer observability.FinishSpan(span, &err)
69
2x

70
2x
    rows, err := s.db.QueryContext(ctx, `
71
2x
        SELECT id, user_id, language, level, question_type, priority_weight, expires_at, created_at
72
2x
        FROM generation_hints
73
2x
        WHERE user_id = $1 AND expires_at > NOW()
74
2x
        ORDER BY created_at ASC
75
2x
    `, userID)
76
2x
    if err != nil {
77
        return nil, contextutils.WrapError(err, "failed to query generation hints")
78
    }
79
2x
    defer func() { _ = rows.Close() }()
80

81
2x
    var hints []GenerationHint
82
2x
    for rows.Next() {
83
1x
        var h GenerationHint
84
1x
        if err := rows.Scan(&h.ID, &h.UserID, &h.Language, &h.Level, &h.QuestionType, &h.PriorityWeight, &h.ExpiresAt, &h.CreatedAt); err != nil {
85
            return nil, contextutils.WrapError(err, "failed to scan generation hint")
86
        }
87
1x
        hints = append(hints, h)
88
    }
89
2x
    if err := rows.Err(); err != nil {
90
        return nil, contextutils.WrapError(err, "error iterating generation hints")
91
    }
92
2x
    return hints, nil
93
}
94

95
// ClearHint deletes a specific hint
96
1x
func (s *GenerationHintService) ClearHint(ctx context.Context, userID int, language, level string, qType models.QuestionType) (err error) {
97
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "clear_generation_hint")
98
1x
    defer observability.FinishSpan(span, &err)
99
1x

100
1x
    _, err = s.db.ExecContext(ctx, `
101
1x
        DELETE FROM generation_hints
102
1x
        WHERE user_id = $1 AND language = $2 AND level = $3 AND question_type = $4
103
1x
    `, userID, language, level, string(qType))
104
1x
    if err != nil {
105
        return contextutils.WrapError(err, "failed to clear generation hint")
106
    }
107
1x
    return nil
108
}
109


			
quizapp internal services worker_service.go
79.3%
Statements
604/762
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "fmt"
7
    "math"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/config"
12
    "quizapp/internal/models"
13
    "quizapp/internal/observability"
14
    contextutils "quizapp/internal/utils"
15

16
    "github.com/lib/pq"
17
    "go.opentelemetry.io/otel/attribute"
18
    "go.opentelemetry.io/otel/codes"
19
    "go.opentelemetry.io/otel/trace"
20
)
21

22
// LearningServiceInterface defines the interface for the learning service
23
type LearningServiceInterface interface {
24
    RecordUserResponse(ctx context.Context, response *models.UserResponse) error
25
    GetUserProgress(ctx context.Context, userID int) (*models.UserProgress, error)
26
    GetWeakestTopics(ctx context.Context, userID, limit int) ([]*models.PerformanceMetrics, error)
27
    ShouldAvoidQuestion(ctx context.Context, userID, questionID int) (bool, error)
28
    GetUserQuestionStats(ctx context.Context, userID int) (*UserQuestionStats, error)
29
    // Priority system methods
30
    RecordAnswerWithPriority(ctx context.Context, userID, questionID, answerIndex int, isCorrect bool, responseTime int) error
31
    // RecordAnswerWithPriorityReturningID records the response and returns the created user_responses.id
32
    RecordAnswerWithPriorityReturningID(ctx context.Context, userID, questionID, answerIndex int, isCorrect bool, responseTime int) (int, error)
33
    MarkQuestionAsKnown(ctx context.Context, userID, questionID int, confidenceLevel *int) error
34
    GetUserLearningPreferences(ctx context.Context, userID int) (*models.UserLearningPreferences, error)
35
    UpdateLastDailyReminderSent(ctx context.Context, userID int) error
36
    CalculatePriorityScore(ctx context.Context, userID, questionID int) (float64, error)
37
    UpdateUserLearningPreferences(ctx context.Context, userID int, prefs *models.UserLearningPreferences) (*models.UserLearningPreferences, error)
38
    GetUserQuestionConfidenceLevel(ctx context.Context, userID, questionID int) (*int, error)
39
    // Analytics methods
40
    GetPriorityScoreDistribution(ctx context.Context) (map[string]interface{}, error)
41
    GetHighPriorityQuestions(ctx context.Context, limit int) ([]map[string]interface{}, error)
42
    GetWeakAreasByTopic(ctx context.Context, limit int) ([]map[string]interface{}, error)
43
    GetLearningPreferencesUsage(ctx context.Context) (map[string]interface{}, error)
44
    GetQuestionTypeGaps(ctx context.Context) ([]map[string]interface{}, error)
45
    GetGenerationSuggestions(ctx context.Context) ([]map[string]interface{}, error)
46
    GetPrioritySystemPerformance(ctx context.Context) (map[string]interface{}, error)
47
    GetBackgroundJobsStatus(ctx context.Context) (map[string]interface{}, error)
48
    // User-specific analytics methods
49
    GetUserPriorityScoreDistribution(ctx context.Context, userID int) (map[string]interface{}, error)
50
    GetUserHighPriorityQuestions(ctx context.Context, userID, limit int) ([]map[string]interface{}, error)
51
    GetUserWeakAreas(ctx context.Context, userID, limit int) ([]map[string]interface{}, error)
52
    // Additional analytics methods for progress API
53
    GetHighPriorityTopics(ctx context.Context, userID int) ([]string, error)
54
    GetGapAnalysis(ctx context.Context, userID int) (map[string]interface{}, error)
55
    GetPriorityDistribution(ctx context.Context, userID int) (map[string]int, error)
56
}
57

58
// UserQuestionStats represents per-user question statistics
59
type UserQuestionStats struct {
60
    UserID           int                `json:"user_id"`
61
    TotalAnswered    int                `json:"total_answered"`
62
    CorrectAnswers   int                `json:"correct_answers"`
63
    IncorrectAnswers int                `json:"incorrect_answers"`
64
    AccuracyRate     float64            `json:"accuracy_rate"`
65
    AnsweredByType   map[string]int     `json:"answered_by_type"`
66
    AnsweredByLevel  map[string]int     `json:"answered_by_level"`
67
    AccuracyByType   map[string]float64 `json:"accuracy_by_type"`
68
    AccuracyByLevel  map[string]float64 `json:"accuracy_by_level"`
69
    AvailableByType  map[string]int     `json:"available_by_type"`
70
    AvailableByLevel map[string]int     `json:"available_by_level"`
71
    RecentlyAnswered int                `json:"recently_answered"` // Within last hour
72
}
73

74
// contextutils.ErrQuestionNotFound is returned when a question does not exist in the database
75
// contextutils.ErrQuestionNotFound is now imported from contextutils
76

77
// LearningService provides methods for managing user learning progress
78
type LearningService struct {
79
    db     *sql.DB
80
    cfg    *config.Config
81
    logger *observability.Logger
82
}
83

84
// NewLearningServiceWithLogger creates a new LearningService with a logger
85
102x
func NewLearningServiceWithLogger(db *sql.DB, cfg *config.Config, logger *observability.Logger) *LearningService {
86
102x
    return &LearningService{
87
102x
        db:     db,
88
102x
        cfg:    cfg,
89
102x
        logger: logger,
90
102x
    }
91
102x
}
92

93
// RecordUserResponse records a user's response to a question and updates metrics
94
36x
func (s *LearningService) RecordUserResponse(ctx context.Context, response *models.UserResponse) (err error) {
95
36x
    ctx, span := observability.TraceLearningFunction(ctx, "record_user_response",
96
36x
        observability.AttributeUserID(response.UserID),
97
36x
        observability.AttributeQuestionID(response.QuestionID),
98
36x
        attribute.Bool("response.is_correct", response.IsCorrect),
99
36x
        attribute.Int("response.time_ms", response.ResponseTimeMs),
100
36x
    )
101
36x
    defer observability.FinishSpan(span, &err)
102
36x

103
36x
    query := `
104
36x
        INSERT INTO user_responses (user_id, question_id, user_answer_index, is_correct, response_time_ms)
105
36x
        VALUES ($1, $2, $3, $4, $5) RETURNING id
106
36x
    `
107
36x

108
36x
    var id int
109
36x
    err = s.db.QueryRowContext(ctx, query,
110
36x
        response.UserID,
111
36x
        response.QuestionID,
112
36x
        response.UserAnswerIndex,
113
36x
        response.IsCorrect,
114
36x
        response.ResponseTimeMs,
115
36x
    ).Scan(&id)
116
36x
    if err != nil {
117
1x
        return err
118
1x
    }
119

120
35x
    response.ID = id
121
35x

122
35x
    // Update performance metrics
123
35x
    return s.updatePerformanceMetrics(ctx, response)
124
}
125

126
35x
func (s *LearningService) updatePerformanceMetrics(ctx context.Context, response *models.UserResponse) (err error) {
127
35x
    ctx, span := observability.TraceLearningFunction(ctx, "update_performance_metrics",
128
35x
        observability.AttributeUserID(response.UserID),
129
35x
        observability.AttributeQuestionID(response.QuestionID),
130
35x
        attribute.Bool("response.is_correct", response.IsCorrect),
131
35x
    )
132
35x
    defer observability.FinishSpan(span, &err)
133
35x

134
35x
    // Get question details
135
35x
    var question *models.Question
136
35x
    question, err = s.getQuestionDetails(ctx, response.QuestionID)
137
35x
    if err != nil {
138
        return err
139
    }
140

141
    // Update or create performance metrics
142
35x
    query := `
143
35x
        INSERT INTO performance_metrics (
144
35x
            user_id, topic, language, level, total_attempts, correct_attempts,
145
35x
            average_response_time_ms, difficulty_adjustment, last_updated
146
35x
        )
147
35x
        VALUES ($1, $2, $3, $4, 1, $5, $6, 0.0, CURRENT_TIMESTAMP)
148
35x
        ON CONFLICT(user_id, topic, language, level) DO UPDATE SET
149
35x
            total_attempts = performance_metrics.total_attempts + 1,
150
35x
            correct_attempts = performance_metrics.correct_attempts + $7,
151
35x
            average_response_time_ms = (performance_metrics.average_response_time_ms * (performance_metrics.total_attempts - 1) + $8) / performance_metrics.total_attempts,
152
35x
            last_updated = CURRENT_TIMESTAMP
153
35x
    `
154
35x

155
35x
    correctIncrement := 0
156
35x
    if response.IsCorrect {
157
18x
        correctIncrement = 1
158
18x
    }
159

160
35x
    _, err = s.db.ExecContext(ctx, query,
161
35x
        response.UserID,
162
35x
        question.TopicCategory,
163
35x
        question.Language,
164
35x
        question.Level,
165
35x
        correctIncrement,                 // For initial correct_attempts in VALUES
166
35x
        float64(response.ResponseTimeMs), // For initial average_response_time_ms in VALUES
167
35x
        correctIncrement,                 // For correct_attempts increment in UPDATE
168
35x
        response.ResponseTimeMs,          // For average_response_time_ms calculation in UPDATE
169
35x
    )
170
35x

171
35x
    return err
172
}
173

174
// getUserByID is a lightweight helper for LearningService to fetch a user row.
175
4x
func (s *LearningService) getUserByID(ctx context.Context, userID int) (*models.User, error) {
176
4x
    query := `
177
4x
        SELECT id, username, email, timezone, password_hash, last_active,
178
4x
               preferred_language, current_level, ai_provider, ai_model,
179
4x
               ai_enabled, ai_api_key, created_at, updated_at
180
4x
        FROM users
181
4x
        WHERE id = $1
182
4x
    `
183
4x

184
4x
    var u models.User
185
4x
    err := s.db.QueryRowContext(ctx, query, userID).Scan(
186
4x
        &u.ID, &u.Username, &u.Email, &u.Timezone, &u.PasswordHash, &u.LastActive,
187
4x
        &u.PreferredLanguage, &u.CurrentLevel, &u.AIProvider, &u.AIModel,
188
4x
        &u.AIEnabled, &u.AIAPIKey, &u.CreatedAt, &u.UpdatedAt,
189
4x
    )
190
4x
    if err != nil {
191
        if err == sql.ErrNoRows {
192
            return nil, nil
193
        }
194
        return nil, err
195
    }
196
4x
    return &u, nil
197
}
198

199
35x
func (s *LearningService) getQuestionDetails(ctx context.Context, questionID int) (result0 *models.Question, err error) {
200
35x
    ctx, span := observability.TraceLearningFunction(ctx, "get_question_details",
201
35x
        observability.AttributeQuestionID(questionID),
202
35x
    )
203
35x
    defer observability.FinishSpan(span, &err)
204
35x

205
35x
    query := `SELECT type, language, level, topic_category FROM questions WHERE id = $1`
206
35x

207
35x
    question := &models.Question{}
208
35x
    var topicCategory sql.NullString
209
35x
    err = s.db.QueryRowContext(ctx, query, questionID).Scan(
210
35x
        &question.Type,
211
35x
        &question.Language,
212
35x
        &question.Level,
213
35x
        &topicCategory,
214
35x
    )
215
35x

216
35x
    if topicCategory.Valid {
217
31x
        question.TopicCategory = topicCategory.String
218
31x
    }
219

220
35x
    return question, err
221
}
222

223
// GetUserProgress retrieves comprehensive learning progress for a user
224
1x
func (s *LearningService) GetUserProgress(ctx context.Context, userID int) (result0 *models.UserProgress, err error) {
225
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_progress",
226
1x
        attribute.String("user.username", ""),
227
1x
        attribute.String("language", ""),
228
1x
        attribute.String("level", ""),
229
1x
    )
230
1x
    defer observability.FinishSpan(span, &err)
231
1x

232
1x
    progress := &models.UserProgress{
233
1x
        PerformanceByTopic: make(map[string]*models.PerformanceMetrics),
234
1x
    }
235
1x

236
1x
    // Get overall stats
237
1x
    overallQuery := `
238
1x
        SELECT
239
1x
            COUNT(*) as total,
240
1x
            COALESCE(SUM(CASE WHEN is_correct THEN 1 ELSE 0 END), 0) as correct
241
1x
        FROM user_responses
242
1x
        WHERE user_id = $1
243
1x
    `
244
1x

245
1x
    err = s.db.QueryRowContext(ctx, overallQuery, userID).Scan(
246
1x
        &progress.TotalQuestions,
247
1x
        &progress.CorrectAnswers,
248
1x
    )
249
1x

250
1x
    if err != nil && err != sql.ErrNoRows {
251
        return nil, err
252
    }
253

254
1x
    if progress.TotalQuestions > 0 {
255
1x
        progress.AccuracyRate = float64(progress.CorrectAnswers) / float64(progress.TotalQuestions) * 100
256
1x
    }
257

258
    // Get performance by topic
259
1x
    metricsQuery := `
260
1x
        SELECT id, topic, language, level, total_attempts, correct_attempts,
261
1x
               average_response_time_ms, difficulty_adjustment, last_updated
262
1x
        FROM performance_metrics
263
1x
        WHERE user_id = $1
264
1x
    `
265
1x

266
1x
    rows, err := s.db.QueryContext(ctx, metricsQuery, userID)
267
1x
    if err != nil {
268
        return nil, err
269
    }
270
1x
    defer func() {
271
1x
        if err := rows.Close(); err != nil {
272
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
273
        }
274
    }()
275

276
1x
    for rows.Next() {
277
1x
        metric := &models.PerformanceMetrics{UserID: userID}
278
1x
        err = rows.Scan(
279
1x
            &metric.ID,
280
1x
            &metric.Topic,
281
1x
            &metric.Language,
282
1x
            &metric.Level,
283
1x
            &metric.TotalAttempts,
284
1x
            &metric.CorrectAttempts,
285
1x
            &metric.AverageResponseTimeMs,
286
1x
            &metric.DifficultyAdjustment,
287
1x
            &metric.LastUpdated,
288
1x
        )
289
1x
        if err != nil {
290
            return nil, err
291
        }
292

293
1x
        key := metric.Topic + "_" + metric.Language + "_" + metric.Level
294
1x
        progress.PerformanceByTopic[key] = metric
295
    }
296

297
    // Identify weak areas (accuracy < 60%)
298
1x
    progress.WeakAreas = s.identifyWeakAreas(progress.PerformanceByTopic)
299
1x

300
1x
    // Get recent activity
301
1x
    progress.RecentActivity, err = s.getRecentActivity(ctx, userID, 10)
302
1x
    if err != nil {
303
        return nil, err
304
    }
305

306
    // Get current level from user
307
1x
    currentLevel, err := s.getCurrentUserLevel(ctx, userID)
308
1x
    if err != nil {
309
        return nil, err
310
    }
311
1x
    progress.CurrentLevel = currentLevel
312
1x

313
1x
    // Suggest level adjustment if needed
314
1x
    progress.SuggestedLevel = s.suggestLevelAdjustment(progress)
315
1x

316
1x
    return progress, nil
317
}
318

319
1x
func (s *LearningService) identifyWeakAreas(metrics map[string]*models.PerformanceMetrics) []string {
320
1x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
321
1x
    // But we could add tracing if we want to track the analysis performance
322
1x
    var weakAreas []string
323
1x

324
1x
    for key, metric := range metrics {
325
1x
        if metric.TotalAttempts > 0 && metric.AccuracyRate() < 60.0 && metric.TotalAttempts >= 3 {
326
            weakAreas = append(weakAreas, key)
327
        }
328
    }
329

330
1x
    return weakAreas
331
}
332

333
1x
func (s *LearningService) getRecentActivity(ctx context.Context, userID, limit int) (result0 []models.UserResponse, err error) {
334
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_recent_activity",
335
1x
        observability.AttributeUserID(userID),
336
1x
        attribute.Int("limit", limit),
337
1x
    )
338
1x
    defer observability.FinishSpan(span, &err)
339
1x

340
1x
    query := `
341
1x
        SELECT id, user_id, question_id, user_answer_index, is_correct, response_time_ms, created_at
342
1x
        FROM user_responses
343
1x
        WHERE user_id = $1
344
1x
        ORDER BY created_at DESC
345
1x
        LIMIT $2
346
1x
    `
347
1x

348
1x
    rows, err := s.db.QueryContext(ctx, query, userID, limit)
349
1x
    if err != nil {
350
        return nil, err
351
    }
352
1x
    defer func() {
353
1x
        if err := rows.Close(); err != nil {
354
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
355
        }
356
    }()
357

358
1x
    var responses []models.UserResponse
359
1x
    for rows.Next() {
360
3x
        var response models.UserResponse
361
3x
        err = rows.Scan(
362
3x
            &response.ID,
363
3x
            &response.UserID,
364
3x
            &response.QuestionID,
365
3x
            &response.UserAnswerIndex,
366
3x
            &response.IsCorrect,
367
3x
            &response.ResponseTimeMs,
368
3x
            &response.CreatedAt,
369
3x
        )
370
3x
        if err != nil {
371
            return nil, err
372
        }
373

374
3x
        responses = append(responses, response)
375
    }
376

377
1x
    return responses, nil
378
}
379

380
1x
func (s *LearningService) getCurrentUserLevel(ctx context.Context, userID int) (result0 string, err error) {
381
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_current_user_level",
382
1x
        observability.AttributeUserID(userID),
383
1x
    )
384
1x
    defer observability.FinishSpan(span, &err)
385
1x

386
1x
    query := `SELECT current_level FROM users WHERE id = $1`
387
1x

388
1x
    var level sql.NullString
389
1x
    err = s.db.QueryRowContext(ctx, query, userID).Scan(&level)
390
1x
    if err != nil {
391
        return "", err
392
    }
393

394
    // Return default level if NULL
395
1x
    if !level.Valid || level.String == "" {
396
        return "A1", nil // Default level
397
    }
398

399
1x
    return level.String, nil
400
}
401

402
13x
func (s *LearningService) suggestLevelAdjustment(progress *models.UserProgress) string {
403
13x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
404
13x
    // But we could add tracing if we want to track the analysis performance
405
13x
    if progress.TotalQuestions < 20 {
406
3x
        return "" // Not enough data
407
3x
    }
408

409
    // If accuracy is consistently high (>85%), suggest level up
410
5x
    if progress.AccuracyRate > 85.0 {
411
2x
        return s.getNextLevel(progress.CurrentLevel)
412
2x
    }
413

414
    // If accuracy is consistently low (<50%), suggest level down
415
3x
    if progress.AccuracyRate < 50.0 {
416
2x
        return s.getPreviousLevel(progress.CurrentLevel)
417
2x
    }
418

419
1x
    return ""
420
}
421

422
10x
func (s *LearningService) getNextLevel(currentLevel string) string {
423
10x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
424
10x
    levels := s.cfg.GetAllLevels()
425
10x

426
10x
    for i, level := range levels {
427
45x
        if level == currentLevel && i < len(levels)-1 {
428
8x
            return levels[i+1]
429
8x
        }
430
    }
431

432
2x
    return currentLevel
433
}
434

435
10x
func (s *LearningService) getPreviousLevel(currentLevel string) string {
436
10x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
437
10x
    levels := s.cfg.GetAllLevels()
438
10x

439
10x
    for i, level := range levels {
440
54x
        if level == currentLevel && i > 0 {
441
8x
            return levels[i-1]
442
8x
        }
443
    }
444

445
2x
    return currentLevel
446
}
447

448
// GetWeakestTopics returns the topics where the user performs poorest
449
1x
func (s *LearningService) GetWeakestTopics(ctx context.Context, userID, limit int) (result0 []*models.PerformanceMetrics, err error) {
450
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_weakest_topics",
451
1x
        observability.AttributeUserID(userID),
452
1x
        attribute.Int("limit", limit),
453
1x
    )
454
1x
    defer observability.FinishSpan(span, &err)
455
1x

456
1x
    query := `
457
1x
        SELECT id, topic, language, level, total_attempts, correct_attempts, average_response_time_ms, difficulty_adjustment, last_updated
458
1x
        FROM performance_metrics
459
1x
        WHERE user_id = $1 AND total_attempts >= 3
460
1x
        ORDER BY (correct_attempts * 1.0 / total_attempts) ASC, last_updated ASC
461
1x
        LIMIT $2
462
1x
    `
463
1x

464
1x
    rows, err := s.db.QueryContext(ctx, query, userID, limit)
465
1x
    if err != nil {
466
        return nil, err
467
    }
468
1x
    defer func() {
469
1x
        if err := rows.Close(); err != nil {
470
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
471
        }
472
    }()
473

474
1x
    var topics []*models.PerformanceMetrics
475
1x
    for rows.Next() {
476
3x
        metric := &models.PerformanceMetrics{UserID: userID}
477
3x
        err = rows.Scan(
478
3x
            &metric.ID,
479
3x
            &metric.Topic,
480
3x
            &metric.Language,
481
3x
            &metric.Level,
482
3x
            &metric.TotalAttempts,
483
3x
            &metric.CorrectAttempts,
484
3x
            &metric.AverageResponseTimeMs,
485
3x
            &metric.DifficultyAdjustment,
486
3x
            &metric.LastUpdated,
487
3x
        )
488
3x
        if err != nil {
489
            return nil, err
490
        }
491
3x
        topics = append(topics, metric)
492
    }
493

494
1x
    return topics, nil
495
}
496

497
// ShouldAvoidQuestion determines if a question should be avoided for a user
498
4x
func (s *LearningService) ShouldAvoidQuestion(ctx context.Context, userID, questionID int) (result0 bool, err error) {
499
4x
    ctx, span := observability.TraceLearningFunction(ctx, "should_avoid_question",
500
4x
        observability.AttributeUserID(userID),
501
4x
        observability.AttributeQuestionID(questionID),
502
4x
    )
503
4x
    defer observability.FinishSpan(span, &err)
504
4x

505
4x
    // Determine user's local 1-day window and convert to UTC timestamps
506
4x
    startUTC, endUTC, _, err := contextutils.UserLocalDayRange(ctx, userID, 1, s.getUserByID)
507
4x
    if err != nil {
508
        return false, contextutils.WrapError(err, "failed to compute user local day range")
509
    }
510

511
4x
    query := `
512
4x
        SELECT COUNT(*)
513
4x
        FROM user_responses
514
4x
        WHERE user_id = $1 AND question_id = $2 AND is_correct = true
515
4x
        AND created_at >= $3 AND created_at < $4
516
4x
    `
517
4x

518
4x
    var count int
519
4x
    err = s.db.QueryRowContext(ctx, query, userID, questionID, startUTC, endUTC).Scan(&count)
520
4x

521
4x
    span.SetAttributes(attribute.Bool("should_avoid", count > 0))
522
4x
    return count > 0, err
523
}
524

525
// GetUserQuestionStats returns comprehensive per-user question statistics
526
1x
func (s *LearningService) GetUserQuestionStats(ctx context.Context, userID int) (result0 *UserQuestionStats, err error) {
527
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_question_stats",
528
1x
        observability.AttributeUserID(userID),
529
1x
    )
530
1x
    defer observability.FinishSpan(span, &err)
531
1x

532
1x
    stats := &UserQuestionStats{
533
1x
        UserID:           userID,
534
1x
        AnsweredByType:   make(map[string]int),
535
1x
        AnsweredByLevel:  make(map[string]int),
536
1x
        AccuracyByType:   make(map[string]float64),
537
1x
        AccuracyByLevel:  make(map[string]float64),
538
1x
        AvailableByType:  make(map[string]int),
539
1x
        AvailableByLevel: make(map[string]int),
540
1x
    }
541
1x

542
1x
    // Get user's language and level preferences
543
1x
    var userLanguage, userLevel string
544
1x
    userQuery := `SELECT COALESCE(preferred_language, 'italian'), COALESCE(current_level, 'B1') FROM users WHERE id = $1`
545
1x
    err = s.db.QueryRowContext(ctx, userQuery, userID).Scan(&userLanguage, &userLevel)
546
1x
    if err != nil {
547
        return nil, err
548
    }
549

550
1x
    span.SetAttributes(
551
1x
        attribute.String("user.language", userLanguage),
552
1x
        attribute.String("user.level", userLevel),
553
1x
    )
554
1x

555
1x
    // Get questions answered by user with stats
556
1x
    answeredQuery := `
557
1x
        SELECT
558
1x
            q.type,
559
1x
            q.level,
560
1x
            COUNT(*) as total,
561
1x
            SUM(CASE WHEN ur.is_correct THEN 1 ELSE 0 END) as correct
562
1x
        FROM user_responses ur
563
1x
        JOIN questions q ON ur.question_id = q.id
564
1x
        WHERE ur.user_id = $1
565
1x
        GROUP BY q.type, q.level
566
1x
    `
567
1x

568
1x
    rows, err := s.db.QueryContext(ctx, answeredQuery, userID)
569
1x
    if err != nil {
570
        return nil, err
571
    }
572
1x
    defer func() {
573
1x
        if err := rows.Close(); err != nil {
574
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
575
        }
576
    }()
577

578
1x
    for rows.Next() {
579
1x
        var qType, level string
580
1x
        var total, correct int
581
1x

582
1x
        if err := rows.Scan(&qType, &level, &total, &correct); err != nil {
583
            return nil, err
584
        }
585

586
1x
        stats.AnsweredByType[qType] += total
587
1x
        stats.AnsweredByLevel[level] += total
588
1x
        stats.TotalAnswered += total
589
1x

590
1x
        // Calculate accuracy rates
591
1x
        accuracy := float64(correct) / float64(total) * 100
592
1x

593
1x
        // For type accuracy, we need to aggregate across levels
594
1x
        if _, exists := stats.AnsweredByType[qType]; exists {
595
1x
            // Recalculate accuracy for this type
596
1x
            typeQuery := `
597
1x
                SELECT
598
1x
                    COUNT(*) as total,
599
1x
                    SUM(CASE WHEN ur.is_correct THEN 1 ELSE 0 END) as correct
600
1x
                FROM user_responses ur
601
1x
                JOIN questions q ON ur.question_id = q.id
602
1x
                WHERE ur.user_id = $1 AND q.type = $2
603
1x
            `
604
1x
            var typeTotal, typeCorrect int
605
1x
            if err := s.db.QueryRowContext(ctx, typeQuery, userID, qType).Scan(&typeTotal, &typeCorrect); err != nil {
606
                s.logger.Warn(ctx, "Failed to scan type query result", map[string]interface{}{"error": err.Error()})
607
            }
608
1x
            if typeTotal > 0 {
609
1x
                stats.AccuracyByType[qType] = float64(typeCorrect) / float64(typeTotal) * 100
610
1x
            }
611
        } else {
612
            stats.AccuracyByType[qType] = accuracy
613
        }
614

615
        // For level accuracy
616
1x
        if _, exists := stats.AnsweredByLevel[level]; exists {
617
1x
            // Recalculate accuracy for this level
618
1x
            levelQuery := `
619
1x
                SELECT
620
1x
                    COUNT(*) as total,
621
1x
                    SUM(CASE WHEN ur.is_correct THEN 1 ELSE 0 END) as correct
622
1x
                FROM user_responses ur
623
1x
                JOIN questions q ON ur.question_id = q.id
624
1x
                WHERE ur.user_id = $1 AND q.level = $2
625
1x
            `
626
1x
            var levelTotal, levelCorrect int
627
1x
            if err := s.db.QueryRowContext(ctx, levelQuery, userID, level).Scan(&levelTotal, &levelCorrect); err != nil {
628
                s.logger.Warn(ctx, "Failed to scan level query result", map[string]interface{}{"error": err.Error()})
629
            }
630
1x
            if levelTotal > 0 {
631
1x
                stats.AccuracyByLevel[level] = float64(levelCorrect) / float64(levelTotal) * 100
632
1x
            }
633
        } else {
634
            stats.AccuracyByLevel[level] = accuracy
635
        }
636
    }
637

638
    // Get available questions (not answered by user) that belong to this user
639
1x
    availableQuery := `
640
1x
        SELECT
641
1x
            q.type,
642
1x
            q.level,
643
1x
            COUNT(*) as available
644
1x
        FROM questions q
645
1x
        JOIN user_questions uq ON uq.question_id = q.id
646
1x
        WHERE uq.user_id = $1
647
1x
        AND q.language = $2
648
1x
        AND q.status = 'active'
649
1x
        AND q.id NOT IN (
650
1x
            SELECT DISTINCT question_id
651
1x
            FROM user_responses
652
1x
            WHERE user_id = $3
653
1x
        )
654
1x
        GROUP BY q.type, q.level
655
1x
    `
656
1x

657
1x
    rows, err = s.db.QueryContext(ctx, availableQuery, userID, userLanguage, userID)
658
1x
    if err != nil {
659
        return nil, err
660
    }
661
1x
    defer func() {
662
1x
        if err := rows.Close(); err != nil {
663
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
664
        }
665
    }()
666

667
1x
    for rows.Next() {
668
        var qType, level string
669
        var available int
670

671
        if err := rows.Scan(&qType, &level, &available); err != nil {
672
            return nil, err
673
        }
674

675
        stats.AvailableByType[qType] += available
676
        stats.AvailableByLevel[level] += available
677
    }
678

679
    // Get recently answered questions (within last hour)
680
1x
    recentQuery := `
681
1x
        SELECT COUNT(*)
682
1x
        FROM user_responses ur
683
1x
        WHERE ur.user_id = $1
684
1x
        AND ur.created_at > NOW() - INTERVAL '1 hour'
685
1x
    `
686
1x

687
1x
    err = s.db.QueryRowContext(ctx, recentQuery, userID).Scan(&stats.RecentlyAnswered)
688
1x
    if err != nil {
689
        stats.RecentlyAnswered = 0 // Default to 0 if query fails
690
    }
691

692
    // Calculate overall correct/incorrect answers and accuracy rate
693
1x
    overallQuery := `
694
1x
        SELECT
695
1x
            COUNT(*) as total,
696
1x
            SUM(CASE WHEN is_correct THEN 1 ELSE 0 END) as correct
697
1x
        FROM user_responses
698
1x
        WHERE user_id = $1
699
1x
    `
700
1x

701
1x
    var total, correct int
702
1x
    err = s.db.QueryRowContext(ctx, overallQuery, userID).Scan(&total, &correct)
703
1x
    if err != nil {
704
        // Default values if query fails
705
        stats.CorrectAnswers = 0
706
        stats.IncorrectAnswers = 0
707
        stats.AccuracyRate = 0.0
708
    } else {
709
1x
        stats.CorrectAnswers = correct
710
1x
        stats.IncorrectAnswers = total - correct
711
1x
        if total > 0 {
712
1x
            stats.AccuracyRate = float64(correct) / float64(total) * 100
713
1x
        } else {
714
            stats.AccuracyRate = 0.0
715
        }
716
    }
717

718
1x
    return stats, nil
719
}
720

721
// PRIORITY SYSTEM METHODS
722

723
// RecordAnswerWithPriority records a user's response and updates priority scores
724
1x
func (s *LearningService) RecordAnswerWithPriority(ctx context.Context, userID, questionID, answerIndex int, isCorrect bool, responseTime int) error {
725
1x
    // Create user response object
726
1x
    response := &models.UserResponse{
727
1x
        UserID:          userID,
728
1x
        QuestionID:      questionID,
729
1x
        UserAnswerIndex: answerIndex,
730
1x
        IsCorrect:       isCorrect,
731
1x
        ResponseTimeMs:  responseTime,
732
1x
        CreatedAt:       time.Now(),
733
1x
    }
734
1x

735
1x
    // Use existing RecordUserResponse method
736
1x
    err := s.RecordUserResponse(ctx, response)
737
1x
    if err != nil {
738
        return contextutils.WrapError(err, "failed to record user response")
739
    }
740

741
    // Update priority score in background
742
1x
    go s.updatePriorityScoreAsync(ctx, userID, questionID)
743
1x

744
1x
    return nil
745
}
746

747
// RecordAnswerWithPriorityReturningID records a user's response, updates priority async, and returns the new user_responses ID
748
2x
func (s *LearningService) RecordAnswerWithPriorityReturningID(ctx context.Context, userID, questionID, answerIndex int, isCorrect bool, responseTime int) (int, error) {
749
2x
    response := &models.UserResponse{
750
2x
        UserID:          userID,
751
2x
        QuestionID:      questionID,
752
2x
        UserAnswerIndex: answerIndex,
753
2x
        IsCorrect:       isCorrect,
754
2x
        ResponseTimeMs:  responseTime,
755
2x
        CreatedAt:       time.Now(),
756
2x
    }
757
2x

758
2x
    // Insert and get ID
759
2x
    if err := s.RecordUserResponse(ctx, response); err != nil {
760
        return 0, contextutils.WrapError(err, "failed to record user response")
761
    }
762

763
    // Update priority score in background
764
2x
    go s.updatePriorityScoreAsync(ctx, userID, questionID)
765
2x

766
2x
    return response.ID, nil
767
}
768

769
// MarkQuestionAsKnown marks a question as known for a user with optional confidence level
770
12x
func (s *LearningService) MarkQuestionAsKnown(ctx context.Context, userID, questionID int, confidenceLevel *int) (err error) {
771
12x
    ctx, span := observability.TraceLearningFunction(ctx, "mark_question_as_known",
772
12x
        observability.AttributeUserID(userID),
773
12x
        observability.AttributeQuestionID(questionID),
774
12x
    )
775
12x
    defer observability.FinishSpan(span, &err)
776
12x

777
12x
    // DEBUG: Log the attempt
778
12x
    s.logger.Debug(ctx, "MarkQuestionAsKnown called", map[string]interface{}{
779
12x
        "user_id":     userID,
780
12x
        "question_id": questionID,
781
12x
    })
782
12x

783
12x
    // Update user_question_metadata table with confidence level
784
12x
    _, err = s.db.ExecContext(ctx, `
785
12x
        INSERT INTO user_question_metadata (user_id, question_id, marked_as_known, marked_as_known_at, confidence_level, created_at, updated_at)
786
12x
        VALUES ($1, $2, TRUE, NOW(), $3, NOW(), NOW())
787
12x
        ON CONFLICT (user_id, question_id) DO UPDATE
788
12x
        SET marked_as_known = TRUE, marked_as_known_at = NOW(), confidence_level = $3, updated_at = NOW()
789
12x
    `, userID, questionID, confidenceLevel)
790
12x
    if err != nil {
791
        // DEBUG: Log the actual error
792
        s.logger.Debug(ctx, "MarkQuestionAsKnown error", map[string]interface{}{
793
            "user_id":     userID,
794
            "question_id": questionID,
795
            "error":       err.Error(),
796
            "error_type":  fmt.Sprintf("%T", err),
797
        })
798

799
        if isForeignKeyConstraintViolation(err) {
800
            s.logger.Debug(ctx, "Foreign key constraint violation detected", map[string]interface{}{
801
                "user_id":     userID,
802
                "question_id": questionID,
803
            })
804
            return contextutils.ErrQuestionNotFound
805
        }
806
        s.logger.Debug(ctx, "Not a foreign key constraint violation, returning original error", map[string]interface{}{
807
            "user_id":     userID,
808
            "question_id": questionID,
809
        })
810
        return err
811
    }
812

813
12x
    s.logger.Debug(ctx, "MarkQuestionAsKnown succeeded", map[string]interface{}{
814
12x
        "user_id":     userID,
815
12x
        "question_id": questionID,
816
12x
    })
817
12x

818
12x
    // Update priority score in background so the new confidence affects selection immediately
819
12x
    go s.updatePriorityScoreAsync(ctx, userID, questionID)
820
12x
    return nil
821
}
822

823
// GetUserLearningPreferences retrieves user learning preferences
824
333x
func (s *LearningService) GetUserLearningPreferences(ctx context.Context, userID int) (result0 *models.UserLearningPreferences, err error) {
825
333x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_learning_preferences",
826
333x
        observability.AttributeUserID(userID),
827
333x
    )
828
333x
    defer observability.FinishSpan(span, &err)
829
333x

830
333x
    var prefs models.UserLearningPreferences
831
333x
    err = s.db.QueryRowContext(ctx, `
832
333x
        SELECT id, user_id, focus_on_weak_areas, include_review_questions, fresh_question_ratio,
833
333x
               known_question_penalty, review_interval_days, weak_area_boost, daily_reminder_enabled,
834
333x
               tts_voice, last_daily_reminder_sent, daily_goal, created_at, updated_at
835
333x
        FROM user_learning_preferences
836
333x
        WHERE user_id = $1
837
333x
    `, userID).Scan(
838
333x
        &prefs.ID, &prefs.UserID, &prefs.FocusOnWeakAreas, &prefs.IncludeReviewQuestions,
839
333x
        &prefs.FreshQuestionRatio, &prefs.KnownQuestionPenalty, &prefs.ReviewIntervalDays,
840
333x
        &prefs.WeakAreaBoost, &prefs.DailyReminderEnabled,
841
333x
        &prefs.TTSVoice,
842
333x
        &prefs.LastDailyReminderSent,
843
333x
        &prefs.DailyGoal,
844
333x
        &prefs.CreatedAt, &prefs.UpdatedAt,
845
333x
    )
846
333x

847
333x
    if err == sql.ErrNoRows {
848
32x
        // Check if user exists before creating default preferences
849
32x
        var userExists bool
850
32x
        err = s.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)", userID).Scan(&userExists)
851
32x
        if err != nil {
852
5x
            return nil, contextutils.WrapError(err, "failed to check if user exists")
853
5x
        }
854
27x
        if !userExists {
855
            return nil, contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "user %d not found", userID)
856
        }
857
        // Create default preferences if none exist
858
27x
        return s.createDefaultPreferences(ctx, userID)
859
    }
860

861
301x
    if err != nil {
862
        return nil, contextutils.WrapError(err, "failed to get user preferences")
863
    }
864

865
301x
    return &prefs, nil
866
}
867

868
// UpdateLastDailyReminderSent updates the last daily reminder sent timestamp for a user
869
3x
func (s *LearningService) UpdateLastDailyReminderSent(ctx context.Context, userID int) (err error) {
870
3x
    ctx, span := observability.TraceLearningFunction(ctx, "update_last_daily_reminder_sent",
871
3x
        observability.AttributeUserID(userID),
872
3x
    )
873
3x
    defer observability.FinishSpan(span, &err)
874
3x

875
3x
    // Use INSERT ... ON CONFLICT to create the record if it doesn't exist
876
3x
    _, err = s.db.ExecContext(ctx, `
877
3x
        INSERT INTO user_learning_preferences (user_id, last_daily_reminder_sent, updated_at)
878
3x
        VALUES ($1, NOW(), NOW())
879
3x
        ON CONFLICT (user_id) DO UPDATE SET
880
3x
            last_daily_reminder_sent = NOW(),
881
3x
            updated_at = NOW()
882
3x
    `, userID)
883
3x
    if err != nil {
884
        return contextutils.WrapError(err, "failed to update last daily reminder sent")
885
    }
886

887
3x
    return nil
888
}
889

890
// UpdateUserLearningPreferences updates user learning preferences
891
9x
func (s *LearningService) UpdateUserLearningPreferences(ctx context.Context, userID int, prefs *models.UserLearningPreferences) (result0 *models.UserLearningPreferences, err error) {
892
9x
    ctx, span := observability.TraceLearningFunction(ctx, "update_user_learning_preferences",
893
9x
        observability.AttributeUserID(userID),
894
9x
        attribute.Bool("prefs.focus_on_weak_areas", prefs.FocusOnWeakAreas),
895
9x
        attribute.Bool("prefs.include_review_questions", prefs.IncludeReviewQuestions),
896
9x
        attribute.Float64("prefs.fresh_question_ratio", prefs.FreshQuestionRatio),
897
9x
        attribute.Float64("prefs.known_question_penalty", prefs.KnownQuestionPenalty),
898
9x
        attribute.Int("prefs.review_interval_days", prefs.ReviewIntervalDays),
899
9x
        attribute.Float64("prefs.weak_area_boost", prefs.WeakAreaBoost),
900
9x
    )
901
9x
    defer func() {
902
9x
        if err != nil {
903
            span.RecordError(err, trace.WithStackTrace(true))
904
            span.SetStatus(codes.Error, err.Error())
905
        }
906
9x
        span.End()
907
    }()
908

909
9x
    var updatedPrefs models.UserLearningPreferences
910
9x
    err = s.db.QueryRowContext(ctx, `
911
9x
        UPDATE user_learning_preferences
912
9x
        SET focus_on_weak_areas = $2, include_review_questions = $3, fresh_question_ratio = $4,
913
9x
            known_question_penalty = $5, review_interval_days = $6, weak_area_boost = $7,
914
9x
            daily_reminder_enabled = $8, tts_voice = $9, daily_goal = COALESCE(NULLIF($10, 0), daily_goal), updated_at = NOW()
915
9x
        WHERE user_id = $1
916
9x
        RETURNING id, user_id, focus_on_weak_areas, include_review_questions, fresh_question_ratio,
917
9x
                  known_question_penalty, review_interval_days, weak_area_boost, daily_reminder_enabled,
918
9x
                  tts_voice, last_daily_reminder_sent, daily_goal, created_at, updated_at
919
9x
    `, userID, prefs.FocusOnWeakAreas, prefs.IncludeReviewQuestions, prefs.FreshQuestionRatio,
920
9x
        prefs.KnownQuestionPenalty, prefs.ReviewIntervalDays, prefs.WeakAreaBoost, prefs.DailyReminderEnabled, prefs.TTSVoice, prefs.DailyGoal).Scan(
921
9x
        &updatedPrefs.ID, &updatedPrefs.UserID, &updatedPrefs.FocusOnWeakAreas, &updatedPrefs.IncludeReviewQuestions,
922
9x
        &updatedPrefs.FreshQuestionRatio, &updatedPrefs.KnownQuestionPenalty, &updatedPrefs.ReviewIntervalDays,
923
9x
        &updatedPrefs.WeakAreaBoost, &updatedPrefs.DailyReminderEnabled, &updatedPrefs.TTSVoice, &updatedPrefs.LastDailyReminderSent,
924
9x
        &updatedPrefs.DailyGoal, &updatedPrefs.CreatedAt, &updatedPrefs.UpdatedAt,
925
9x
    )
926
9x

927
9x
    if err == sql.ErrNoRows {
928
7x
        // If no preferences exist, create them with the provided values
929
7x
        return s.createPreferencesWithValues(ctx, userID, prefs)
930
7x
    }
931

932
2x
    if err != nil {
933
        return nil, contextutils.WrapError(err, "failed to update user preferences")
934
    }
935

936
2x
    return &updatedPrefs, nil
937
}
938

939
// createPreferencesWithValues creates learning preferences for a user with the provided values
940
7x
func (s *LearningService) createPreferencesWithValues(ctx context.Context, userID int, prefs *models.UserLearningPreferences) (result0 *models.UserLearningPreferences, err error) {
941
7x
    ctx, span := observability.TraceLearningFunction(ctx, "create_preferences_with_values",
942
7x
        observability.AttributeUserID(userID),
943
7x
    )
944
7x
    defer func() {
945
7x
        if err != nil {
946
            span.RecordError(err, trace.WithStackTrace(true))
947
            span.SetStatus(codes.Error, err.Error())
948
        }
949
7x
        span.End()
950
    }()
951

952
    // Use the provided values, falling back to defaults for any missing fields
953
7x
    defaultPrefs := s.GetDefaultLearningPreferences()
954
7x
    prefs.UserID = userID
955
7x

956
7x
    // Merge provided values with defaults
957
7x
    if prefs.FocusOnWeakAreas == defaultPrefs.FocusOnWeakAreas && !prefs.FocusOnWeakAreas {
958
        prefs.FocusOnWeakAreas = defaultPrefs.FocusOnWeakAreas
959
    }
960
7x
    if prefs.IncludeReviewQuestions == defaultPrefs.IncludeReviewQuestions && !prefs.IncludeReviewQuestions {
961
        prefs.IncludeReviewQuestions = defaultPrefs.IncludeReviewQuestions
962
    }
963
7x
    if prefs.FreshQuestionRatio == 0 {
964
2x
        prefs.FreshQuestionRatio = defaultPrefs.FreshQuestionRatio
965
2x
    }
966
7x
    if prefs.KnownQuestionPenalty == 0 {
967
2x
        prefs.KnownQuestionPenalty = defaultPrefs.KnownQuestionPenalty
968
2x
    }
969
7x
    if prefs.ReviewIntervalDays == 0 {
970
2x
        prefs.ReviewIntervalDays = defaultPrefs.ReviewIntervalDays
971
2x
    }
972
7x
    if prefs.WeakAreaBoost == 0 {
973
2x
        prefs.WeakAreaBoost = defaultPrefs.WeakAreaBoost
974
2x
    }
975
7x
    if prefs.DailyGoal == 0 {
976
7x
        prefs.DailyGoal = defaultPrefs.DailyGoal
977
7x
    }
978

979
    // Try to insert with ON CONFLICT DO NOTHING to handle race conditions
980
7x
    _, err = s.db.ExecContext(ctx, `
981
7x
        INSERT INTO user_learning_preferences (user_id, focus_on_weak_areas, include_review_questions,
982
7x
                                               fresh_question_ratio, known_question_penalty,
983
7x
                                               review_interval_days, weak_area_boost, daily_reminder_enabled,
984
7x
                                               tts_voice, daily_goal, created_at, updated_at)
985
7x
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
986
7x
        ON CONFLICT (user_id) DO NOTHING
987
7x
    `, userID, prefs.FocusOnWeakAreas, prefs.IncludeReviewQuestions,
988
7x
        prefs.FreshQuestionRatio, prefs.KnownQuestionPenalty,
989
7x
        prefs.ReviewIntervalDays, prefs.WeakAreaBoost, prefs.DailyReminderEnabled, prefs.TTSVoice, prefs.DailyGoal)
990
7x
    if err != nil {
991
        return nil, contextutils.WrapError(err, "failed to create preferences with values")
992
    }
993

994
    // Now fetch the preferences (either the ones we just created or the ones created by another concurrent request)
995
7x
    err = s.db.QueryRowContext(ctx, `
996
7x
        SELECT id, user_id, focus_on_weak_areas, include_review_questions, fresh_question_ratio,
997
7x
               known_question_penalty, review_interval_days, weak_area_boost, daily_reminder_enabled,
998
7x
               tts_voice, last_daily_reminder_sent, daily_goal, created_at, updated_at
999
7x
        FROM user_learning_preferences
1000
7x
        WHERE user_id = $1
1001
7x
    `, userID).Scan(
1002
7x
        &prefs.ID, &prefs.UserID, &prefs.FocusOnWeakAreas, &prefs.IncludeReviewQuestions,
1003
7x
        &prefs.FreshQuestionRatio, &prefs.KnownQuestionPenalty, &prefs.ReviewIntervalDays,
1004
7x
        &prefs.WeakAreaBoost, &prefs.DailyReminderEnabled, &prefs.TTSVoice, &prefs.LastDailyReminderSent,
1005
7x
        &prefs.DailyGoal, &prefs.CreatedAt, &prefs.UpdatedAt,
1006
7x
    )
1007
7x
    if err != nil {
1008
        return nil, contextutils.WrapError(err, "failed to fetch created preferences")
1009
    }
1010

1011
7x
    return prefs, nil
1012
}
1013

1014
// createDefaultPreferences creates default learning preferences for a user
1015
27x
func (s *LearningService) createDefaultPreferences(ctx context.Context, userID int) (result0 *models.UserLearningPreferences, err error) {
1016
27x
    ctx, span := observability.TraceLearningFunction(ctx, "create_default_preferences",
1017
27x
        observability.AttributeUserID(userID),
1018
27x
    )
1019
27x
    defer func() {
1020
27x
        if err != nil {
1021
            span.RecordError(err, trace.WithStackTrace(true))
1022
            span.SetStatus(codes.Error, err.Error())
1023
        }
1024
27x
        span.End()
1025
    }()
1026

1027
27x
    defaultPrefs := s.GetDefaultLearningPreferences()
1028
27x
    defaultPrefs.UserID = userID
1029
27x

1030
27x
    // Try to insert with ON CONFLICT DO NOTHING to handle race conditions
1031
27x
    _, err = s.db.ExecContext(ctx, `
1032
27x
        INSERT INTO user_learning_preferences (user_id, focus_on_weak_areas, include_review_questions,
1033
27x
                                               fresh_question_ratio, known_question_penalty,
1034
27x
                                               review_interval_days, weak_area_boost, daily_reminder_enabled,
1035
27x
                                               tts_voice, daily_goal, created_at, updated_at)
1036
27x
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
1037
27x
        ON CONFLICT (user_id) DO NOTHING
1038
27x
    `, userID, defaultPrefs.FocusOnWeakAreas, defaultPrefs.IncludeReviewQuestions,
1039
27x
        defaultPrefs.FreshQuestionRatio, defaultPrefs.KnownQuestionPenalty,
1040
27x
        defaultPrefs.ReviewIntervalDays, defaultPrefs.WeakAreaBoost, defaultPrefs.DailyReminderEnabled, defaultPrefs.TTSVoice, defaultPrefs.DailyGoal)
1041
27x
    if err != nil {
1042
        return nil, contextutils.WrapError(err, "failed to create default preferences")
1043
    }
1044

1045
    // Now fetch the preferences (either the ones we just created or the ones created by another concurrent request)
1046
27x
    err = s.db.QueryRowContext(ctx, `
1047
27x
        SELECT id, user_id, focus_on_weak_areas, include_review_questions, fresh_question_ratio,
1048
27x
               known_question_penalty, review_interval_days, weak_area_boost, daily_reminder_enabled,
1049
27x
               tts_voice, last_daily_reminder_sent, daily_goal, created_at, updated_at
1050
27x
        FROM user_learning_preferences
1051
27x
        WHERE user_id = $1
1052
27x
    `, userID).Scan(
1053
27x
        &defaultPrefs.ID, &defaultPrefs.UserID, &defaultPrefs.FocusOnWeakAreas, &defaultPrefs.IncludeReviewQuestions,
1054
27x
        &defaultPrefs.FreshQuestionRatio, &defaultPrefs.KnownQuestionPenalty, &defaultPrefs.ReviewIntervalDays,
1055
27x
        &defaultPrefs.WeakAreaBoost, &defaultPrefs.DailyReminderEnabled, &defaultPrefs.TTSVoice, &defaultPrefs.LastDailyReminderSent,
1056
27x
        &defaultPrefs.DailyGoal, &defaultPrefs.CreatedAt, &defaultPrefs.UpdatedAt,
1057
27x
    )
1058
27x
    if err != nil {
1059
        return nil, contextutils.WrapError(err, "failed to fetch created preferences")
1060
    }
1061

1062
27x
    return defaultPrefs, nil
1063
}
1064

1065
// GetDefaultLearningPreferences returns default learning preferences
1066
34x
func (s *LearningService) GetDefaultLearningPreferences() *models.UserLearningPreferences {
1067
34x
    return &models.UserLearningPreferences{
1068
34x
        FocusOnWeakAreas:       true,
1069
34x
        IncludeReviewQuestions: true,
1070
34x
        FreshQuestionRatio:     0.3,
1071
34x
        KnownQuestionPenalty:   0.1,
1072
34x
        ReviewIntervalDays:     7,
1073
34x
        WeakAreaBoost:          2.0,
1074
34x
        DailyReminderEnabled:   false, // Default to false for daily reminders
1075
34x
        DailyGoal:              10,
1076
34x
        TTSVoice:               "",
1077
34x
    }
1078
34x
}
1079

1080
// CalculatePriorityScore calculates priority score for a specific question for a user
1081
28x
func (s *LearningService) CalculatePriorityScore(ctx context.Context, userID, questionID int) (result0 float64, err error) {
1082
28x
    ctx, span := observability.TraceLearningFunction(ctx, "calculate_priority_score",
1083
28x
        observability.AttributeUserID(userID),
1084
28x
        observability.AttributeQuestionID(questionID),
1085
28x
    )
1086
28x
    defer func() {
1087
28x
        if err != nil {
1088
7x
            span.RecordError(err, trace.WithStackTrace(true))
1089
7x
            span.SetStatus(codes.Error, err.Error())
1090
7x
        }
1091
28x
        span.End()
1092
    }()
1093

1094
    // Get user preferences
1095
28x
    prefs, err := s.GetUserLearningPreferences(ctx, userID)
1096
28x
    if err != nil {
1097
5x
        return 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get user preferences: %w", err)
1098
5x
    }
1099

1100
    // Get user's performance history for this question
1101
23x
    performance, err := s.getQuestionPerformance(ctx, userID, questionID)
1102
23x
    if err != nil {
1103
2x
        return 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get question performance: %w", err)
1104
2x
    }
1105

1106
    // Calculate components
1107
21x
    baseScore := 100.0
1108
21x
    performanceMultiplier := s.calculatePerformanceMultiplier(performance, prefs.WeakAreaBoost)
1109
21x
    spacedRepetitionBoost := s.calculateSpacedRepetitionBoost(performance.LastSeenAt)
1110
21x
    userPreferenceMultiplier := s.calculateUserPreferenceMultiplier(performance, prefs)
1111
21x
    freshnessBoost := s.calculateFreshnessBoost(performance.TimesAnswered)
1112
21x

1113
21x
    // Final score with bounds checking
1114
21x
    finalScore := baseScore * performanceMultiplier * spacedRepetitionBoost * userPreferenceMultiplier * freshnessBoost
1115
21x

1116
21x
    // Apply bounds to prevent extreme values
1117
21x
    if finalScore < 1.0 {
1118
        finalScore = 1.0
1119
    } else if finalScore > 1000.0 {
1120
        finalScore = 1000.0
1121
    }
1122

1123
21x
    return finalScore, nil
1124
}
1125

1126
// updatePriorityScoreAsync updates priority score for a question asynchronously
1127
16x
func (s *LearningService) updatePriorityScoreAsync(ctx context.Context, userID, questionID int) {
1128
16x
    ctx, span := observability.TraceLearningFunction(ctx, "update_priority_score_async",
1129
16x
        observability.AttributeUserID(userID),
1130
16x
        observability.AttributeQuestionID(questionID),
1131
16x
    )
1132
16x
    defer span.End()
1133
16x

1134
16x
    score, err := s.CalculatePriorityScore(ctx, userID, questionID)
1135
16x
    if err != nil {
1136
7x
        s.logger.Error(ctx, "Failed to calculate priority score", err, map[string]interface{}{
1137
7x
            "user_id":     userID,
1138
7x
            "question_id": questionID,
1139
7x
        })
1140
7x
        return
1141
7x
    }
1142

1143
    // Update or insert priority score
1144
9x
    _, err = s.db.ExecContext(ctx, `
1145
9x
        INSERT INTO question_priority_scores (user_id, question_id, priority_score, last_calculated_at, created_at, updated_at)
1146
9x
        VALUES ($1, $2, $3, NOW(), NOW(), NOW())
1147
9x
        ON CONFLICT (user_id, question_id) DO UPDATE
1148
9x
        SET priority_score = $3, last_calculated_at = NOW(), updated_at = NOW()
1149
9x
    `, userID, questionID, score)
1150
9x
    if err != nil {
1151
        s.logger.Error(ctx, "Failed to update priority score", err, map[string]interface{}{
1152
            "user_id":     userID,
1153
            "question_id": questionID,
1154
            "score":       score,
1155
        })
1156
    }
1157
}
1158

1159
// QuestionPerformance represents performance data for a specific question
1160
type QuestionPerformance struct {
1161
    TimesAnswered   int
1162
    CorrectAnswers  int
1163
    LastSeenAt      *time.Time
1164
    MarkedAsKnown   bool
1165
    MarkedAsKnownAt *time.Time
1166
    ConfidenceLevel *int
1167
}
1168

1169
// getQuestionPerformance retrieves performance data for a specific question
1170
23x
func (s *LearningService) getQuestionPerformance(ctx context.Context, userID, questionID int) (result0 *QuestionPerformance, err error) {
1171
23x
    ctx, span := observability.TraceLearningFunction(ctx, "get_question_performance",
1172
23x
        observability.AttributeUserID(userID),
1173
23x
        observability.AttributeQuestionID(questionID),
1174
23x
    )
1175
23x
    defer func() {
1176
23x
        if err != nil {
1177
2x
            span.RecordError(err, trace.WithStackTrace(true))
1178
2x
            span.SetStatus(codes.Error, err.Error())
1179
2x
        }
1180
23x
        span.End()
1181
    }()
1182

1183
23x
    performance := &QuestionPerformance{}
1184
23x

1185
23x
    // Get response statistics
1186
23x
    err = s.db.QueryRowContext(ctx, `
1187
23x
        SELECT
1188
23x
            COUNT(*) as times_answered,
1189
23x
            COALESCE(SUM(CASE WHEN is_correct THEN 1 ELSE 0 END), 0) as correct_answers,
1190
23x
            MAX(created_at) as last_seen_at
1191
23x
        FROM user_responses
1192
23x
        WHERE user_id = $1 AND question_id = $2
1193
23x
    `, userID, questionID).Scan(
1194
23x
        &performance.TimesAnswered,
1195
23x
        &performance.CorrectAnswers,
1196
23x
        &performance.LastSeenAt,
1197
23x
    )
1198
23x

1199
23x
    if err != nil && err != sql.ErrNoRows {
1200
2x
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get response statistics: %w", err)
1201
2x
    }
1202

1203
    // Get metadata
1204
21x
    var markedAsKnownAt sql.NullTime
1205
21x
    var confidenceLevel sql.NullInt32
1206
21x
    err = s.db.QueryRowContext(ctx, `
1207
21x
        SELECT marked_as_known, marked_as_known_at, confidence_level
1208
21x
        FROM user_question_metadata
1209
21x
        WHERE user_id = $1 AND question_id = $2
1210
21x
    `, userID, questionID).Scan(&performance.MarkedAsKnown, &markedAsKnownAt, &confidenceLevel)
1211
21x

1212
21x
    if err != nil && err != sql.ErrNoRows {
1213
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get question metadata: %w", err)
1214
    }
1215

1216
21x
    if markedAsKnownAt.Valid {
1217
15x
        performance.MarkedAsKnownAt = &markedAsKnownAt.Time
1218
15x
    }
1219

1220
21x
    if confidenceLevel.Valid {
1221
15x
        level := int(confidenceLevel.Int32)
1222
15x
        performance.ConfidenceLevel = &level
1223
15x
    }
1224

1225
21x
    return performance, nil
1226
}
1227

1228
// calculatePerformanceMultiplier calculates the performance-based multiplier
1229
21x
func (s *LearningService) calculatePerformanceMultiplier(performance *QuestionPerformance, weakAreaBoost float64) float64 {
1230
21x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
1231
21x
    if performance.TimesAnswered == 0 {
1232
16x
        return 1.0 // Neutral for new questions
1233
16x
    }
1234

1235
5x
    errorRate := float64(performance.TimesAnswered-performance.CorrectAnswers) / float64(performance.TimesAnswered)
1236
5x
    successRate := float64(performance.CorrectAnswers) / float64(performance.TimesAnswered)
1237
5x

1238
5x
    // Apply weak area boost for questions with high error rates
1239
5x
    multiplier := 1.0 + (errorRate * weakAreaBoost) - (successRate * 0.5)
1240
5x

1241
5x
    // Apply bounds to prevent extreme values
1242
5x
    if multiplier < 0.1 {
1243
        multiplier = 0.1
1244
    } else if multiplier > 10.0 {
1245
        multiplier = 10.0
1246
    }
1247

1248
5x
    return multiplier
1249
}
1250

1251
// calculateSpacedRepetitionBoost calculates the spaced repetition boost
1252
21x
func (s *LearningService) calculateSpacedRepetitionBoost(lastSeenAt *time.Time) float64 {
1253
21x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
1254
21x
    if lastSeenAt == nil {
1255
16x
        return 1.0 // No boost for never-seen questions
1256
16x
    }
1257

1258
5x
    daysSinceLastSeen := time.Since(*lastSeenAt).Hours() / 24.0
1259
5x
    boost := 1.0 + (daysSinceLastSeen * 0.1)
1260
5x

1261
5x
    // Cap the boost at 5.0x multiplier
1262
5x
    return math.Min(boost, 5.0)
1263
}
1264

1265
// calculateUserPreferenceMultiplier calculates how user preference ("mark known" with confidence)
1266
// influences question priority.
1267
//
1268
// New policy:
1269
// - Confidence 1â2: show MORE (boost priority) â multipliers > 1
1270
// - Confidence 3: neutral â multiplier = 1
1271
// - Confidence 4â5: show LESS (reduce priority) â multiplier < 1 using KnownQuestionPenalty
1272
21x
func (s *LearningService) calculateUserPreferenceMultiplier(performance *QuestionPerformance, prefs *models.UserLearningPreferences) float64 {
1273
21x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
1274
21x
    if performance.MarkedAsKnown {
1275
15x
        if performance.ConfidenceLevel != nil {
1276
15x
            switch *performance.ConfidenceLevel {
1277
4x
            case 1:
1278
4x
                // Low confidence â increase frequency noticeably
1279
4x
                return 1.25
1280
2x
            case 2:
1281
2x
                // Some confidence â slight increase in frequency
1282
2x
                return 1.10
1283
2x
            case 3:
1284
2x
                // Neutral â no change
1285
2x
                return 1.0
1286
2x
            case 4:
1287
2x
                // Very confident â decrease frequency using half of penalty
1288
2x
                return prefs.KnownQuestionPenalty * 0.5
1289
5x
            case 5:
1290
5x
                // Extremely confident â strong decrease using 10% of penalty
1291
5x
                return prefs.KnownQuestionPenalty * 0.1
1292
            default:
1293
                return 1.0
1294
            }
1295
        }
1296
        // Fallback when confidence not provided â use configured penalty
1297
        return prefs.KnownQuestionPenalty
1298
    }
1299
6x
    return 1.0
1300
}
1301

1302
// calculateFreshnessBoost calculates the freshness boost for new questions
1303
21x
func (s *LearningService) calculateFreshnessBoost(timesAnswered int) float64 {
1304
21x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
1305
21x
    if timesAnswered == 0 {
1306
16x
        return 1.5 // Boost for fresh questions
1307
16x
    }
1308
5x
    return 1.0
1309
}
1310

1311
// isForeignKeyConstraintViolation checks if the error is a foreign key constraint violation
1312
func isForeignKeyConstraintViolation(err error) bool {
1313
    if err == nil {
1314
        return false
1315
    }
1316

1317
    // Check for PostgreSQL foreign key constraint violation error code
1318
    if pqErr, ok := err.(*pq.Error); ok {
1319
        // PostgreSQL error code 23503 is for foreign key constraint violations
1320
        if pqErr.Code == "23503" {
1321
            return true
1322
        }
1323
    }
1324

1325
    // Also check for the error message pattern as a fallback
1326
    errorStr := err.Error()
1327
    return strings.Contains(errorStr, "violates foreign key constraint")
1328
}
1329

1330
// Analytics Methods
1331

1332
// GetPriorityScoreDistribution returns the distribution of priority scores
1333
1x
func (s *LearningService) GetPriorityScoreDistribution(ctx context.Context) (result0 map[string]interface{}, err error) {
1334
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_priority_score_distribution")
1335
1x
    defer func() {
1336
1x
        if err != nil {
1337
            span.RecordError(err, trace.WithStackTrace(true))
1338
            span.SetStatus(codes.Error, err.Error())
1339
        }
1340
1x
        span.End()
1341
    }()
1342

1343
1x
    query := `
1344
1x
        SELECT
1345
1x
            COUNT(CASE WHEN qps.priority_score > 200 THEN 1 END) as high,
1346
1x
            COUNT(CASE WHEN qps.priority_score BETWEEN 100 AND 200 THEN 1 END) as medium,
1347
1x
            COUNT(CASE WHEN qps.priority_score < 100 THEN 1 END) as low,
1348
1x
            AVG(qps.priority_score) as average
1349
1x
        FROM question_priority_scores qps
1350
1x
        JOIN questions q ON qps.question_id = q.id
1351
1x
        WHERE qps.priority_score > 0
1352
1x
    `
1353
1x

1354
1x
    var high, medium, low int
1355
1x
    var average sql.NullFloat64
1356
1x

1357
1x
    err = s.db.QueryRowContext(ctx, query).Scan(&high, &medium, &low, &average)
1358
1x
    if err != nil {
1359
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get priority score distribution: %w", err)
1360
    }
1361

1362
1x
    result := map[string]interface{}{
1363
1x
        "high":    high,
1364
1x
        "medium":  medium,
1365
1x
        "low":     low,
1366
1x
        "average": 0.0,
1367
1x
    }
1368
1x

1369
1x
    if average.Valid {
1370
1x
        result["average"] = average.Float64
1371
1x
    }
1372

1373
1x
    span.SetAttributes(
1374
1x
        attribute.Int("high_count", high),
1375
1x
        attribute.Int("medium_count", medium),
1376
1x
        attribute.Int("low_count", low),
1377
1x
        attribute.Float64("average_score", result["average"].(float64)),
1378
1x
    )
1379
1x

1380
1x
    return result, nil
1381
}
1382

1383
// GetHighPriorityQuestions returns the highest priority questions
1384
1x
func (s *LearningService) GetHighPriorityQuestions(ctx context.Context, limit int) (result0 []map[string]interface{}, err error) {
1385
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_high_priority_questions",
1386
1x
        attribute.Int("limit", limit),
1387
1x
    )
1388
1x
    defer func() {
1389
1x
        if err != nil {
1390
            span.RecordError(err, trace.WithStackTrace(true))
1391
            span.SetStatus(codes.Error, err.Error())
1392
        }
1393
1x
        span.End()
1394
    }()
1395

1396
1x
    query := `
1397
1x
        SELECT
1398
1x
            q.type as question_type,
1399
1x
            q.level,
1400
1x
            q.topic_category as topic,
1401
1x
            qps.priority_score
1402
1x
        FROM question_priority_scores qps
1403
1x
        JOIN questions q ON qps.question_id = q.id
1404
1x
        WHERE qps.priority_score > 200
1405
1x
        ORDER BY qps.priority_score DESC
1406
1x
        LIMIT $1
1407
1x
    `
1408
1x

1409
1x
    rows, err := s.db.QueryContext(ctx, query, limit)
1410
1x
    if err != nil {
1411
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get high priority questions: %w", err)
1412
    }
1413
1x
    defer func() {
1414
1x
        if err := rows.Close(); err != nil {
1415
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1416
        }
1417
    }()
1418

1419
1x
    var questions []map[string]interface{}
1420
1x
    for rows.Next() {
1421
3x
        var questionType, level, topic sql.NullString
1422
3x
        var priorityScore float64
1423
3x

1424
3x
        err = rows.Scan(&questionType, &level, &topic, &priorityScore)
1425
3x
        if err != nil {
1426
            continue
1427
        }
1428

1429
3x
        question := map[string]interface{}{
1430
3x
            "question_type":  questionType.String,
1431
3x
            "level":          level.String,
1432
3x
            "topic":          topic.String,
1433
3x
            "priority_score": priorityScore,
1434
3x
        }
1435
3x
        questions = append(questions, question)
1436
    }
1437

1438
1x
    span.SetAttributes(attribute.Int("questions_count", len(questions)))
1439
1x
    return questions, nil
1440
}
1441

1442
// GetWeakAreasByTopic returns weak areas by topic
1443
1x
func (s *LearningService) GetWeakAreasByTopic(ctx context.Context, limit int) (result0 []map[string]interface{}, err error) {
1444
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_weak_areas_by_topic",
1445
1x
        attribute.Int("limit", limit),
1446
1x
    )
1447
1x
    defer func() {
1448
1x
        if err != nil {
1449
            span.RecordError(err, trace.WithStackTrace(true))
1450
            span.SetStatus(codes.Error, err.Error())
1451
        }
1452
1x
        span.End()
1453
    }()
1454

1455
1x
    query := `
1456
1x
        SELECT
1457
1x
            topic,
1458
1x
            SUM(total_attempts) as total_attempts,
1459
1x
            SUM(correct_attempts) as correct_attempts
1460
1x
        FROM performance_metrics
1461
1x
        WHERE total_attempts > 0
1462
1x
        GROUP BY topic
1463
1x
        ORDER BY (SUM(correct_attempts)::float / SUM(total_attempts)) ASC
1464
1x
        LIMIT $1
1465
1x
    `
1466
1x

1467
1x
    rows, err := s.db.QueryContext(ctx, query, limit)
1468
1x
    if err != nil {
1469
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get weak areas: %w", err)
1470
    }
1471
1x
    defer func() {
1472
1x
        if err := rows.Close(); err != nil {
1473
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1474
        }
1475
    }()
1476

1477
1x
    var weakAreas []map[string]interface{}
1478
1x
    for rows.Next() {
1479
1x
        var topic sql.NullString
1480
1x
        var totalAttempts, correctAttempts int
1481
1x

1482
1x
        err = rows.Scan(&topic, &totalAttempts, &correctAttempts)
1483
1x
        if err != nil {
1484
            continue
1485
        }
1486

1487
1x
        area := map[string]interface{}{
1488
1x
            "topic":            topic.String,
1489
1x
            "total_attempts":   totalAttempts,
1490
1x
            "correct_attempts": correctAttempts,
1491
1x
        }
1492
1x
        weakAreas = append(weakAreas, area)
1493
    }
1494

1495
1x
    span.SetAttributes(attribute.Int("weak_areas_count", len(weakAreas)))
1496
1x
    return weakAreas, nil
1497
}
1498

1499
// GetLearningPreferencesUsage returns learning preferences usage statistics
1500
1x
func (s *LearningService) GetLearningPreferencesUsage(ctx context.Context) (result0 map[string]interface{}, err error) {
1501
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_learning_preferences_usage")
1502
1x
    defer func() {
1503
1x
        if err != nil {
1504
            span.RecordError(err, trace.WithStackTrace(true))
1505
            span.SetStatus(codes.Error, err.Error())
1506
        }
1507
1x
        span.End()
1508
    }()
1509

1510
1x
    query := `
1511
1x
        SELECT
1512
1x
            COUNT(*) as total_users,
1513
1x
            AVG(focus_on_weak_areas::int) as avg_focus_on_weak_areas,
1514
1x
            AVG(fresh_question_ratio) as avg_fresh_question_ratio,
1515
1x
            AVG(weak_area_boost) as avg_weak_area_boost,
1516
1x
            AVG(known_question_penalty) as avg_known_question_penalty
1517
1x
        FROM user_learning_preferences
1518
1x
    `
1519
1x

1520
1x
    var totalUsers int
1521
1x
    var avgFocusOnWeakAreas, avgFreshQuestionRatio, avgWeakAreaBoost, avgKnownQuestionPenalty sql.NullFloat64
1522
1x

1523
1x
    err = s.db.QueryRowContext(ctx, query).Scan(
1524
1x
        &totalUsers,
1525
1x
        &avgFocusOnWeakAreas,
1526
1x
        &avgFreshQuestionRatio,
1527
1x
        &avgWeakAreaBoost,
1528
1x
        &avgKnownQuestionPenalty,
1529
1x
    )
1530
1x
    if err != nil {
1531
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get learning preferences usage: %w", err)
1532
    }
1533

1534
1x
    result := map[string]interface{}{
1535
1x
        "total_users":          0,
1536
1x
        "focusOnWeakAreas":     false,
1537
1x
        "freshQuestionRatio":   0.3,
1538
1x
        "weakAreaBoost":        2.0,
1539
1x
        "knownQuestionPenalty": 0.1,
1540
1x
    }
1541
1x

1542
1x
    if totalUsers > 0 {
1543
1x
        result["total_users"] = totalUsers
1544
1x
        if avgFocusOnWeakAreas.Valid {
1545
1x
            result["focusOnWeakAreas"] = avgFocusOnWeakAreas.Float64 > 0.5
1546
1x
        }
1547
1x
        if avgFreshQuestionRatio.Valid {
1548
1x
            result["freshQuestionRatio"] = avgFreshQuestionRatio.Float64
1549
1x
        }
1550
1x
        if avgWeakAreaBoost.Valid {
1551
1x
            result["weakAreaBoost"] = avgWeakAreaBoost.Float64
1552
1x
        }
1553
1x
        if avgKnownQuestionPenalty.Valid {
1554
1x
            result["knownQuestionPenalty"] = avgKnownQuestionPenalty.Float64
1555
1x
        }
1556
    }
1557

1558
1x
    span.SetAttributes(
1559
1x
        attribute.Int("total_users", result["total_users"].(int)),
1560
1x
        attribute.Bool("focus_on_weak_areas", result["focusOnWeakAreas"].(bool)),
1561
1x
        attribute.Float64("fresh_question_ratio", result["freshQuestionRatio"].(float64)),
1562
1x
        attribute.Float64("weak_area_boost", result["weakAreaBoost"].(float64)),
1563
1x
        attribute.Float64("known_question_penalty", result["knownQuestionPenalty"].(float64)),
1564
1x
    )
1565
1x

1566
1x
    return result, nil
1567
}
1568

1569
// GetQuestionTypeGaps returns gaps in question types
1570
1x
func (s *LearningService) GetQuestionTypeGaps(ctx context.Context) (result0 []map[string]interface{}, err error) {
1571
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_question_type_gaps")
1572
1x
    defer func() {
1573
1x
        if err != nil {
1574
            span.RecordError(err, trace.WithStackTrace(true))
1575
            span.SetStatus(codes.Error, err.Error())
1576
        }
1577
1x
        span.End()
1578
    }()
1579

1580
1x
    query := `
1581
1x
        SELECT
1582
1x
            q.type as question_type,
1583
1x
            q.level,
1584
1x
            COUNT(q.id) as available,
1585
1x
            COUNT(qps.question_id) as with_priority_scores
1586
1x
        FROM questions q
1587
1x
        LEFT JOIN question_priority_scores qps ON q.id = qps.question_id
1588
1x
        GROUP BY q.type, q.level
1589
1x
        HAVING COUNT(qps.question_id) < COUNT(q.id) * 0.8
1590
1x
        ORDER BY (COUNT(qps.question_id)::float / COUNT(q.id)) ASC
1591
1x
    `
1592
1x

1593
1x
    rows, err := s.db.QueryContext(ctx, query)
1594
1x
    if err != nil {
1595
        span.SetAttributes(attribute.String("error.type", "database_query_failed"), attribute.String("error", err.Error()))
1596
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get question type gaps: %w", err)
1597
    }
1598
1x
    defer func() {
1599
1x
        if err := rows.Close(); err != nil {
1600
            s.logger.Warn(ctx, "Failed to close rows in GetQuestionTypeGaps", map[string]interface{}{"error": err.Error()})
1601
        }
1602
    }()
1603

1604
1x
    var gaps []map[string]interface{}
1605
1x
    var scanErrors int
1606
1x

1607
1x
    for rows.Next() {
1608
3x
        var questionType, level sql.NullString
1609
3x
        var available, withPriorityScores int
1610
3x

1611
3x
        err = rows.Scan(&questionType, &level, &available, &withPriorityScores)
1612
3x
        if err != nil {
1613
            scanErrors++
1614
            span.SetAttributes(attribute.String("error.type", "row_scan_failed"), attribute.String("error", err.Error()))
1615
            continue
1616
        }
1617

1618
3x
        gap := map[string]interface{}{
1619
3x
            "question_type": questionType.String,
1620
3x
            "level":         level.String,
1621
3x
            "available":     available,
1622
3x
            "demand":        available - withPriorityScores,
1623
3x
        }
1624
3x
        gaps = append(gaps, gap)
1625
    }
1626

1627
1x
    if err := rows.Err(); err != nil {
1628
        span.SetAttributes(attribute.String("error.type", "rows_iteration_failed"), attribute.String("error", err.Error()))
1629
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "error during rows iteration: %w", err)
1630
    }
1631

1632
1x
    span.SetAttributes(
1633
1x
        attribute.Int("gaps_count", len(gaps)),
1634
1x
        attribute.Int("scan_errors", scanErrors),
1635
1x
    )
1636
1x
    return gaps, nil
1637
}
1638

1639
// GetGenerationSuggestions returns suggestions for question generation
1640
1x
func (s *LearningService) GetGenerationSuggestions(ctx context.Context) (result0 []map[string]interface{}, err error) {
1641
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_generation_suggestions")
1642
1x
    defer func() {
1643
1x
        if err != nil {
1644
            span.RecordError(err, trace.WithStackTrace(true))
1645
            span.SetStatus(codes.Error, err.Error())
1646
        }
1647
1x
        span.End()
1648
    }()
1649

1650
1x
    query := `
1651
1x
        SELECT
1652
1x
            q.type as question_type,
1653
1x
            q.level,
1654
1x
            q.language,
1655
1x
            COUNT(q.id) as available,
1656
1x
            COUNT(CASE WHEN qps.priority_score > 100 THEN 1 END) as high_priority,
1657
1x
            AVG(qps.priority_score) as avg_priority
1658
1x
        FROM questions q
1659
1x
        LEFT JOIN question_priority_scores qps ON q.id = qps.question_id
1660
1x
        GROUP BY q.type, q.level, q.language
1661
1x
        HAVING COUNT(q.id) < 50 OR COUNT(CASE WHEN qps.priority_score > 100 THEN 1 END) < 10
1662
1x
        ORDER BY COUNT(q.id) ASC, AVG(qps.priority_score) DESC
1663
1x
    `
1664
1x

1665
1x
    rows, err := s.db.QueryContext(ctx, query)
1666
1x
    if err != nil {
1667
        span.SetAttributes(attribute.String("error.type", "database_query_failed"), attribute.String("error", err.Error()))
1668
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get generation suggestions: %w", err)
1669
    }
1670
1x
    defer func() {
1671
1x
        if err := rows.Close(); err != nil {
1672
            s.logger.Warn(ctx, "Failed to close rows in GetGenerationSuggestions", map[string]interface{}{"error": err.Error()})
1673
        }
1674
    }()
1675

1676
1x
    var suggestions []map[string]interface{}
1677
1x
    var scanErrors int
1678
1x

1679
1x
    for rows.Next() {
1680
1x
        var questionType, level, language sql.NullString
1681
1x
        var available, highPriority int
1682
1x
        var avgPriority sql.NullFloat64
1683
1x

1684
1x
        err = rows.Scan(&questionType, &level, &language, &available, &highPriority, &avgPriority)
1685
1x
        if err != nil {
1686
            scanErrors++
1687
            span.SetAttributes(attribute.String("error.type", "row_scan_failed"), attribute.String("error", err.Error()))
1688
            continue
1689
        }
1690

1691
1x
        suggestion := map[string]interface{}{
1692
1x
            "question_type":  questionType.String,
1693
1x
            "level":          level.String,
1694
1x
            "language":       language.String,
1695
1x
            "available":      available,
1696
1x
            "high_priority":  highPriority,
1697
1x
            "avg_priority":   0.0,
1698
1x
            "priority_score": 0.0,
1699
1x
        }
1700
1x

1701
1x
        if avgPriority.Valid {
1702
            suggestion["avg_priority"] = avgPriority.Float64
1703
            suggestion["priority_score"] = avgPriority.Float64
1704
        }
1705

1706
1x
        suggestions = append(suggestions, suggestion)
1707
    }
1708

1709
1x
    if err := rows.Err(); err != nil {
1710
        span.SetAttributes(attribute.String("error.type", "rows_iteration_failed"), attribute.String("error", err.Error()))
1711
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "error during rows iteration: %w", err)
1712
    }
1713

1714
1x
    span.SetAttributes(
1715
1x
        attribute.Int("suggestions_count", len(suggestions)),
1716
1x
        attribute.Int("scan_errors", scanErrors),
1717
1x
    )
1718
1x
    return suggestions, nil
1719
}
1720

1721
// GetPrioritySystemPerformance returns performance metrics for the priority system
1722
1x
func (s *LearningService) GetPrioritySystemPerformance(ctx context.Context) (result0 map[string]interface{}, err error) {
1723
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_priority_system_performance")
1724
1x
    defer func() {
1725
1x
        if err != nil {
1726
            span.RecordError(err, trace.WithStackTrace(true))
1727
            span.SetStatus(codes.Error, err.Error())
1728
        }
1729
1x
        span.End()
1730
    }()
1731

1732
    // This is a simplified implementation - in a real system, this would track actual performance metrics
1733
1x
    query := `
1734
1x
        SELECT
1735
1x
            COUNT(*) as total_calculations,
1736
1x
            AVG(priority_score) as avg_score,
1737
1x
            MAX(last_calculated_at) as last_calculation
1738
1x
        FROM question_priority_scores
1739
1x
        WHERE last_calculated_at > NOW() - INTERVAL '1 hour'
1740
1x
    `
1741
1x

1742
1x
    var totalCalculations int
1743
1x
    var avgScore sql.NullFloat64
1744
1x
    var lastCalculation sql.NullTime
1745
1x

1746
1x
    err = s.db.QueryRowContext(ctx, query).Scan(&totalCalculations, &avgScore, &lastCalculation)
1747
1x
    if err != nil {
1748
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get priority system performance: %w", err)
1749
    }
1750

1751
1x
    result := map[string]interface{}{
1752
1x
        "calculationsPerSecond": float64(totalCalculations) / 3600.0, // Per hour converted to per second
1753
1x
        "avgCalculationTime":    0.0,                                 // Would need to track actual calculation times
1754
1x
        "avgQueryTime":          0.0,                                 // Would need to track actual query times
1755
1x
        "memoryUsage":           0.0,                                 // Would need to track actual memory usage
1756
1x
        "avgScore":              0.0,                                 // Default value
1757
1x
    }
1758
1x

1759
1x
    if avgScore.Valid {
1760
        result["avgScore"] = avgScore.Float64
1761
    }
1762

1763
1x
    if lastCalculation.Valid {
1764
        result["lastCalculation"] = lastCalculation.Time.Format(time.RFC3339)
1765
    }
1766

1767
1x
    span.SetAttributes(
1768
1x
        attribute.Float64("calculations_per_second", result["calculationsPerSecond"].(float64)),
1769
1x
        attribute.Float64("avg_score", result["avgScore"].(float64)),
1770
1x
        attribute.Int("total_calculations", totalCalculations),
1771
1x
    )
1772
1x

1773
1x
    return result, nil
1774
}
1775

1776
// GetBackgroundJobsStatus returns the status of background jobs
1777
1x
func (s *LearningService) GetBackgroundJobsStatus(ctx context.Context) (result0 map[string]interface{}, err error) {
1778
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_background_jobs_status")
1779
1x
    defer func() {
1780
1x
        if err != nil {
1781
            span.RecordError(err, trace.WithStackTrace(true))
1782
            span.SetStatus(codes.Error, err.Error())
1783
        }
1784
1x
        span.End()
1785
    }()
1786

1787
    // This is a simplified implementation - in a real system, this would track actual background job status
1788
1x
    query := `
1789
1x
        SELECT
1790
1x
            COUNT(*) as total_updates,
1791
1x
            MAX(updated_at) as last_update
1792
1x
        FROM question_priority_scores
1793
1x
        WHERE updated_at > NOW() - INTERVAL '1 minute'
1794
1x
    `
1795
1x

1796
1x
    var totalUpdates int
1797
1x
    var lastUpdate sql.NullTime
1798
1x

1799
1x
    err = s.db.QueryRowContext(ctx, query).Scan(&totalUpdates, &lastUpdate)
1800
1x
    if err != nil {
1801
        return nil, contextutils.WrapError(err, "failed to get background jobs status")
1802
    }
1803

1804
1x
    result := map[string]interface{}{
1805
1x
        "priorityUpdates": totalUpdates,
1806
1x
        "lastUpdate":      "N/A",
1807
1x
        "queueSize":       0, // Would need to track actual queue size
1808
1x
        "status":          "healthy",
1809
1x
    }
1810
1x

1811
1x
    if lastUpdate.Valid {
1812
        result["lastUpdate"] = lastUpdate.Time.Format(time.RFC3339)
1813
    }
1814

1815
1x
    if totalUpdates == 0 {
1816
1x
        result["status"] = "idle"
1817
1x
    }
1818

1819
1x
    span.SetAttributes(
1820
1x
        attribute.Int("priority_updates", totalUpdates),
1821
1x
        attribute.String("status", result["status"].(string)),
1822
1x
        attribute.Int("queue_size", result["queueSize"].(int)),
1823
1x
    )
1824
1x

1825
1x
    return result, nil
1826
}
1827

1828
// GetUserPriorityScoreDistribution returns priority score distribution for a specific user
1829
5x
func (s *LearningService) GetUserPriorityScoreDistribution(ctx context.Context, userID int) (result0 map[string]interface{}, err error) {
1830
5x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_priority_score_distribution",
1831
5x
        observability.AttributeUserID(userID),
1832
5x
    )
1833
5x
    defer func() {
1834
5x
        if err != nil {
1835
            span.RecordError(err, trace.WithStackTrace(true))
1836
            span.SetStatus(codes.Error, err.Error())
1837
        }
1838
5x
        span.End()
1839
    }()
1840

1841
5x
    query := `
1842
5x
        SELECT
1843
5x
            COUNT(CASE WHEN priority_score > 200 THEN 1 END) as high,
1844
5x
            COUNT(CASE WHEN priority_score BETWEEN 100 AND 200 THEN 1 END) as medium,
1845
5x
            COUNT(CASE WHEN priority_score < 100 THEN 1 END) as low,
1846
5x
            AVG(priority_score) as average
1847
5x
        FROM question_priority_scores
1848
5x
        WHERE user_id = $1 AND priority_score > 0
1849
5x
    `
1850
5x

1851
5x
    var high, medium, low int
1852
5x
    var average sql.NullFloat64
1853
5x

1854
5x
    err = s.db.QueryRowContext(ctx, query, userID).Scan(&high, &medium, &low, &average)
1855
5x
    if err != nil {
1856
        return nil, contextutils.WrapError(err, "failed to get user priority score distribution")
1857
    }
1858

1859
5x
    result := map[string]interface{}{
1860
5x
        "high":    high,
1861
5x
        "medium":  medium,
1862
5x
        "low":     low,
1863
5x
        "average": 0.0,
1864
5x
    }
1865
5x

1866
5x
    if average.Valid {
1867
2x
        result["average"] = average.Float64
1868
2x
    }
1869

1870
5x
    span.SetAttributes(
1871
5x
        attribute.Int("high_count", high),
1872
5x
        attribute.Int("medium_count", medium),
1873
5x
        attribute.Int("low_count", low),
1874
5x
        attribute.Float64("average_score", result["average"].(float64)),
1875
5x
    )
1876
5x

1877
5x
    return result, nil
1878
}
1879

1880
// GetUserHighPriorityQuestions returns the highest priority questions for a specific user
1881
6x
func (s *LearningService) GetUserHighPriorityQuestions(ctx context.Context, userID, limit int) (result0 []map[string]interface{}, err error) {
1882
6x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_high_priority_questions",
1883
6x
        observability.AttributeUserID(userID),
1884
6x
        attribute.Int("limit", limit),
1885
6x
    )
1886
6x
    defer func() {
1887
6x
        if err != nil {
1888
            span.RecordError(err, trace.WithStackTrace(true))
1889
            span.SetStatus(codes.Error, err.Error())
1890
        }
1891
6x
        span.End()
1892
    }()
1893

1894
6x
    query := `
1895
6x
        SELECT
1896
6x
            q.type as question_type,
1897
6x
            q.level,
1898
6x
            q.topic_category as topic,
1899
6x
            qps.priority_score
1900
6x
        FROM question_priority_scores qps
1901
6x
        JOIN questions q ON qps.question_id = q.id
1902
6x
        WHERE qps.user_id = $1 AND qps.priority_score > 200
1903
6x
        ORDER BY qps.priority_score DESC
1904
6x
        LIMIT $2
1905
6x
    `
1906
6x

1907
6x
    rows, err := s.db.QueryContext(ctx, query, userID, limit)
1908
6x
    if err != nil {
1909
        return nil, contextutils.WrapError(err, "failed to get user high priority questions")
1910
    }
1911
6x
    defer func() {
1912
6x
        if err := rows.Close(); err != nil {
1913
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1914
        }
1915
    }()
1916

1917
6x
    var questions []map[string]interface{}
1918
6x
    for rows.Next() {
1919
10x
        var questionType, level, topic sql.NullString
1920
10x
        var priorityScore float64
1921
10x

1922
10x
        err = rows.Scan(&questionType, &level, &topic, &priorityScore)
1923
10x
        if err != nil {
1924
            continue
1925
        }
1926

1927
10x
        question := map[string]interface{}{
1928
10x
            "question_type":  questionType.String,
1929
10x
            "level":          level.String,
1930
10x
            "topic":          topic.String,
1931
10x
            "priority_score": priorityScore,
1932
10x
        }
1933
10x
        questions = append(questions, question)
1934
    }
1935

1936
6x
    span.SetAttributes(attribute.Int("questions_count", len(questions)))
1937
6x
    return questions, nil
1938
}
1939

1940
// GetUserWeakAreas returns weak areas for a specific user
1941
8x
func (s *LearningService) GetUserWeakAreas(ctx context.Context, userID, limit int) (result0 []map[string]interface{}, err error) {
1942
8x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_weak_areas",
1943
8x
        observability.AttributeUserID(userID),
1944
8x
        attribute.Int("limit", limit),
1945
8x
    )
1946
8x
    defer func() {
1947
8x
        if err != nil {
1948
1x
            span.RecordError(err, trace.WithStackTrace(true))
1949
1x
            span.SetStatus(codes.Error, err.Error())
1950
1x
        }
1951
8x
        span.End()
1952
    }()
1953

1954
8x
    query := `
1955
8x
        SELECT
1956
8x
            topic,
1957
8x
            total_attempts,
1958
8x
            correct_attempts
1959
8x
        FROM performance_metrics
1960
8x
        WHERE user_id = $1 AND total_attempts > 0
1961
8x
        ORDER BY (correct_attempts::float / total_attempts) ASC
1962
8x
        LIMIT $2
1963
8x
    `
1964
8x

1965
8x
    rows, err := s.db.QueryContext(ctx, query, userID, limit)
1966
8x
    if err != nil {
1967
1x
        return nil, contextutils.WrapError(err, "failed to get user weak areas")
1968
1x
    }
1969
7x
    defer func() {
1970
7x
        if err := rows.Close(); err != nil {
1971
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1972
        }
1973
    }()
1974

1975
7x
    var weakAreas []map[string]interface{}
1976
7x
    for rows.Next() {
1977
11x
        var topic sql.NullString
1978
11x
        var totalAttempts, correctAttempts int
1979
11x

1980
11x
        err = rows.Scan(&topic, &totalAttempts, &correctAttempts)
1981
11x
        if err != nil {
1982
            continue
1983
        }
1984

1985
11x
        area := map[string]interface{}{
1986
11x
            "topic":            topic.String,
1987
11x
            "total_attempts":   totalAttempts,
1988
11x
            "correct_attempts": correctAttempts,
1989
11x
        }
1990
11x
        weakAreas = append(weakAreas, area)
1991
    }
1992

1993
7x
    span.SetAttributes(attribute.Int("weak_areas_count", len(weakAreas)))
1994
7x
    return weakAreas, nil
1995
}
1996

1997
// Priority generation methods moved to worker
1998

1999
// GetHighPriorityTopics returns topics with high average priority scores for a user
2000
2x
func (s *LearningService) GetHighPriorityTopics(ctx context.Context, userID int) (result0 []string, err error) {
2001
2x
    ctx, span := observability.TraceLearningFunction(ctx, "get_high_priority_topics",
2002
2x
        observability.AttributeUserID(userID),
2003
2x
    )
2004
2x
    defer func() {
2005
2x
        if err != nil {
2006
            span.RecordError(err, trace.WithStackTrace(true))
2007
            span.SetStatus(codes.Error, err.Error())
2008
        }
2009
2x
        span.End()
2010
    }()
2011

2012
2x
    query := `
2013
2x
        SELECT q.topic_category, AVG(qps.priority_score) as avg_score
2014
2x
        FROM questions q
2015
2x
        JOIN user_questions uq ON q.id = uq.question_id
2016
2x
        JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
2017
2x
        WHERE uq.user_id = $1
2018
2x
        AND q.topic_category IS NOT NULL
2019
2x
        AND q.topic_category != ''
2020
2x
        GROUP BY q.topic_category
2021
2x
        HAVING AVG(qps.priority_score) >= 150.0
2022
2x
        ORDER BY avg_score DESC
2023
2x
        LIMIT 5
2024
2x
    `
2025
2x

2026
2x
    rows, err := s.db.QueryContext(ctx, query, userID)
2027
2x
    if err != nil {
2028
        return nil, contextutils.WrapError(err, "failed to get high priority topics")
2029
    }
2030
2x
    defer func() {
2031
2x
        if err := rows.Close(); err != nil {
2032
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2033
        }
2034
    }()
2035

2036
2x
    var topics []string
2037
2x
    for rows.Next() {
2038
2x
        var topic string
2039
2x
        var avgScore float64
2040
2x
        if err := rows.Scan(&topic, &avgScore); err != nil {
2041
            continue
2042
        }
2043
2x
        topics = append(topics, topic)
2044
    }
2045

2046
2x
    span.SetAttributes(attribute.Int("topics_count", len(topics)))
2047
2x
    // Ensure we always return a slice, not nil
2048
2x
    if topics == nil {
2049
1x
        topics = []string{}
2050
1x
    }
2051
2x
    return topics, nil
2052
}
2053

2054
// GetGapAnalysis identifies areas with poor user performance (knowledge gaps)
2055
2x
func (s *LearningService) GetGapAnalysis(ctx context.Context, userID int) (result0 map[string]interface{}, err error) {
2056
2x
    ctx, span := observability.TraceLearningFunction(ctx, "get_gap_analysis",
2057
2x
        observability.AttributeUserID(userID),
2058
2x
    )
2059
2x
    defer func() {
2060
2x
        if err != nil {
2061
            span.RecordError(err, trace.WithStackTrace(true))
2062
            span.SetStatus(codes.Error, err.Error())
2063
        }
2064
2x
        span.End()
2065
    }()
2066

2067
    // Query to find areas where user has poor performance (low accuracy)
2068
2x
    query := `
2069
2x
        SELECT
2070
2x
            pm.topic,
2071
2x
            COUNT(*) as total_questions,
2072
2x
            ROUND((pm.correct_attempts * 100.0 / pm.total_attempts), 2) as accuracy_percentage
2073
2x
        FROM performance_metrics pm
2074
2x
        WHERE pm.user_id = $1
2075
2x
        AND pm.total_attempts >= 3
2076
2x
        AND (pm.correct_attempts * 100.0 / pm.total_attempts) < 70.0
2077
2x
        GROUP BY pm.topic, pm.correct_attempts, pm.total_attempts
2078
2x
        ORDER BY accuracy_percentage ASC
2079
2x
        LIMIT 10
2080
2x
    `
2081
2x

2082
2x
    rows, err := s.db.QueryContext(ctx, query, userID)
2083
2x
    if err != nil {
2084
        return nil, contextutils.WrapError(err, "failed to get gap analysis")
2085
    }
2086
2x
    defer func() {
2087
2x
        if err := rows.Close(); err != nil {
2088
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2089
        }
2090
    }()
2091

2092
2x
    gaps := make(map[string]interface{})
2093
2x
    for rows.Next() {
2094
2x
        var topic string
2095
2x
        var totalQuestions int
2096
2x
        var accuracyPercentage sql.NullFloat64
2097
2x

2098
2x
        if err := rows.Scan(&topic, &totalQuestions, &accuracyPercentage); err != nil {
2099
            continue
2100
        }
2101

2102
2x
        gapInfo := map[string]interface{}{
2103
2x
            "topic":               topic,
2104
2x
            "total_questions":     totalQuestions,
2105
2x
            "accuracy_percentage": 0.0,
2106
2x
        }
2107
2x

2108
2x
        if accuracyPercentage.Valid {
2109
2x
            gapInfo["accuracy_percentage"] = accuracyPercentage.Float64
2110
2x
        }
2111

2112
2x
        gaps[topic] = gapInfo
2113
    }
2114

2115
2x
    span.SetAttributes(attribute.Int("gaps_count", len(gaps)))
2116
2x
    return gaps, nil
2117
}
2118

2119
// GetPriorityDistribution returns the distribution of priority scores by topic for a user
2120
2x
func (s *LearningService) GetPriorityDistribution(ctx context.Context, userID int) (result0 map[string]int, err error) {
2121
2x
    ctx, span := observability.TraceLearningFunction(ctx, "get_priority_distribution",
2122
2x
        observability.AttributeUserID(userID),
2123
2x
    )
2124
2x
    defer func() {
2125
2x
        if err != nil {
2126
            span.RecordError(err, trace.WithStackTrace(true))
2127
            span.SetStatus(codes.Error, err.Error())
2128
        }
2129
2x
        span.End()
2130
    }()
2131

2132
    // Query to get priority score distribution by topic
2133
2x
    query := `
2134
2x
        SELECT q.topic_category, COUNT(*) as question_count
2135
2x
        FROM questions q
2136
2x
        JOIN user_questions uq ON q.id = uq.question_id
2137
2x
        JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
2138
2x
        WHERE uq.user_id = $1
2139
2x
        AND q.topic_category IS NOT NULL
2140
2x
        AND q.topic_category != ''
2141
2x
        GROUP BY q.topic_category
2142
2x
        ORDER BY question_count DESC
2143
2x
    `
2144
2x

2145
2x
    rows, err := s.db.QueryContext(ctx, query, userID)
2146
2x
    if err != nil {
2147
        return nil, contextutils.WrapError(err, "failed to get priority distribution")
2148
    }
2149
2x
    defer func() {
2150
2x
        if err := rows.Close(); err != nil {
2151
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2152
        }
2153
    }()
2154

2155
2x
    distribution := make(map[string]int)
2156
2x
    for rows.Next() {
2157
3x
        var topic string
2158
3x
        var count int
2159
3x
        if err := rows.Scan(&topic, &count); err != nil {
2160
            continue
2161
        }
2162
3x
        distribution[topic] = count
2163
    }
2164

2165
2x
    span.SetAttributes(attribute.Int("topics_count", len(distribution)))
2166
2x
    return distribution, nil
2167
}
2168

2169
// GetUserQuestionConfidenceLevel retrieves the confidence level for a specific question and user
2170
1x
func (s *LearningService) GetUserQuestionConfidenceLevel(ctx context.Context, userID, questionID int) (result0 *int, err error) {
2171
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_question_confidence_level",
2172
1x
        observability.AttributeUserID(userID),
2173
1x
        observability.AttributeQuestionID(questionID),
2174
1x
    )
2175
1x
    defer func() {
2176
1x
        if err != nil {
2177
            span.RecordError(err, trace.WithStackTrace(true))
2178
            span.SetStatus(codes.Error, err.Error())
2179
        }
2180
1x
        span.End()
2181
    }()
2182

2183
1x
    query := `
2184
1x
        SELECT confidence_level
2185
1x
        FROM user_question_metadata
2186
1x
        WHERE user_id = $1 AND question_id = $2
2187
1x
    `
2188
1x

2189
1x
    var confidenceLevel sql.NullInt32
2190
1x
    err = s.db.QueryRowContext(ctx, query, userID, questionID).Scan(&confidenceLevel)
2191
1x
    if err != nil {
2192
        if err == sql.ErrNoRows {
2193
            // No confidence level recorded for this user-question pair
2194
            return nil, nil
2195
        }
2196
        return nil, contextutils.WrapError(err, "failed to get user question confidence level")
2197
    }
2198

2199
1x
    if confidenceLevel.Valid {
2200
1x
        level := int(confidenceLevel.Int32)
2201
1x
        return &level, nil
2202
1x
    }
2203

2204
    return nil, nil
2205
}
2206


			
quizapp internal services worker_service.go
81.8%
Statements
260/318
1
package services
2

3
import (
4
    "bytes"
5
    "context"
6
    "encoding/json"
7
    "fmt"
8
    "io"
9
    "net/http"
10
    "regexp"
11
    "strings"
12
    "time"
13

14
    "quizapp/internal/config"
15
    "quizapp/internal/observability"
16
    contextutils "quizapp/internal/utils"
17

18
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
19
    "go.opentelemetry.io/otel/attribute"
20
    "go.opentelemetry.io/otel/trace"
21
)
22

23
// uuidRegex matches standard UUID format (8-4-4-4-12 hex digits)
24
var uuidRegex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
25

26
// Linear API constants
27
const (
28
    // LinearAPIEndpoint is the base URL for Linear's GraphQL API
29
    LinearAPIEndpoint = "https://api.linear.app/graphql"
30
    // LinearHTTPTimeout is the timeout for Linear API requests
31
    LinearHTTPTimeout = 30 * time.Second
32
)
33

34
// LinearService handles Linear API integration
35
type LinearService struct {
36
    config     *config.Config
37
    httpClient *http.Client
38
    logger     *observability.Logger
39
    apiURL     string // Allow overriding API endpoint for testing
40
}
41

42
// LinearIssueResponse represents the response from Linear API
43
type LinearIssueResponse struct {
44
    Data struct {
45
        IssueCreate struct {
46
            Success bool `json:"success"`
47
            Issue   struct {
48
                ID    string `json:"id"`
49
                Title string `json:"title"`
50
                URL   string `json:"url"`
51
            } `json:"issue"`
52
        } `json:"issueCreate"`
53
    } `json:"data"`
54
    Errors []struct {
55
        Message    string                 `json:"message"`
56
        Extensions map[string]interface{} `json:"extensions,omitempty"`
57
        Path       []interface{}          `json:"path,omitempty"`
58
    } `json:"errors,omitempty"`
59
}
60

61
// LinearIssueResult represents the result of creating a Linear issue
62
type LinearIssueResult struct {
63
    IssueID  string `json:"issue_id"`
64
    IssueURL string `json:"issue_url"`
65
    Title    string `json:"title"`
66
}
67

68
// NewLinearService creates a new Linear service instance
69
2x
func NewLinearService(cfg *config.Config, logger *observability.Logger) *LinearService {
70
2x
    return &LinearService{
71
2x
        config: cfg,
72
2x
        httpClient: &http.Client{
73
2x
            Timeout: LinearHTTPTimeout,
74
2x
            Transport: otelhttp.NewTransport(http.DefaultTransport,
75
2x
                otelhttp.WithSpanOptions(trace.WithSpanKind(trace.SpanKindClient)),
76
2x
            ),
77
2x
        },
78
2x
        logger: logger,
79
2x
        apiURL: LinearAPIEndpoint,
80
2x
    }
81
2x
}
82

83
// NewLinearServiceWithURL creates a new LinearService instance with a custom API URL (for testing)
84
34x
func NewLinearServiceWithURL(cfg *config.Config, logger *observability.Logger, apiURL string) *LinearService {
85
34x
    return &LinearService{
86
34x
        config: cfg,
87
34x
        httpClient: &http.Client{
88
34x
            Timeout: LinearHTTPTimeout,
89
34x
            Transport: otelhttp.NewTransport(http.DefaultTransport,
90
34x
                otelhttp.WithSpanOptions(trace.WithSpanKind(trace.SpanKindClient)),
91
34x
            ),
92
34x
        },
93
34x
        logger: logger,
94
34x
        apiURL: apiURL,
95
34x
    }
96
34x
}
97

98
// getTeamIDByName looks up a team ID by name, or returns the ID if it's already a UUID
99
16x
func (s *LinearService) getTeamIDByName(ctx context.Context, teamIdentifier string) (string, error) {
100
16x
    // If it looks like a UUID, return it as-is (case-insensitive check)
101
16x
    if uuidRegex.MatchString(strings.ToLower(teamIdentifier)) {
102
10x
        return teamIdentifier, nil
103
10x
    }
104

105
    // Otherwise, query Linear for teams
106
6x
    query := `
107
6x
        query Teams {
108
6x
            teams {
109
6x
                nodes {
110
6x
                    id
111
6x
                    name
112
6x
                }
113
6x
            }
114
6x
        }
115
6x
    `
116
6x

117
6x
    requestBody := map[string]interface{}{
118
6x
        "query": query,
119
6x
    }
120
6x

121
6x
    jsonData, err := json.Marshal(requestBody)
122
6x
    if err != nil {
123
        return "", contextutils.WrapError(err, "failed to marshal team lookup request")
124
    }
125

126
6x
    apiURL := s.apiURL
127
6x
    if apiURL == "" {
128
        apiURL = LinearAPIEndpoint
129
    }
130
6x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData))
131
6x
    if err != nil {
132
        return "", contextutils.WrapError(err, "failed to create team lookup request")
133
    }
134

135
6x
    req.Header.Set("Content-Type", "application/json")
136
6x
    req.Header.Set("Authorization", s.config.Linear.APIKey)
137
6x
    req.Header.Set("User-Agent", "quizapp/1.0")
138
6x

139
6x
    resp, err := s.httpClient.Do(req)
140
6x
    if err != nil {
141
        return "", contextutils.WrapErrorf(err, "failed to query Linear teams")
142
    }
143
6x
    defer func() {
144
6x
        if closeErr := resp.Body.Close(); closeErr != nil {
145
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": closeErr.Error()})
146
        }
147
    }()
148

149
6x
    body, err := io.ReadAll(resp.Body)
150
6x
    if err != nil {
151
        return "", contextutils.WrapError(err, "failed to read team lookup response")
152
    }
153

154
6x
    if resp.StatusCode != http.StatusOK {
155
2x
        return "", contextutils.NewAppError(
156
2x
            contextutils.ErrorCodeServiceUnavailable,
157
2x
            contextutils.SeverityError,
158
2x
            fmt.Sprintf("Linear API returned status %d when looking up teams: %s", resp.StatusCode, string(body)),
159
2x
            "",
160
2x
        )
161
2x
    }
162

163
4x
    var teamResponse struct {
164
4x
        Data struct {
165
4x
            Teams struct {
166
4x
                Nodes []struct {
167
4x
                    ID   string `json:"id"`
168
4x
                    Name string `json:"name"`
169
4x
                } `json:"nodes"`
170
4x
            } `json:"teams"`
171
4x
        } `json:"data"`
172
4x
        Errors []struct {
173
4x
            Message string `json:"message"`
174
4x
        } `json:"errors,omitempty"`
175
4x
    }
176
4x

177
4x
    if err := json.Unmarshal(body, &teamResponse); err != nil {
178
1x
        return "", contextutils.WrapError(err, "failed to unmarshal team lookup response")
179
1x
    }
180

181
3x
    if len(teamResponse.Errors) > 0 {
182
        return "", contextutils.NewAppError(
183
            contextutils.ErrorCodeServiceUnavailable,
184
            contextutils.SeverityError,
185
            fmt.Sprintf("Linear API error when looking up teams: %s", teamResponse.Errors[0].Message),
186
            "",
187
        )
188
    }
189

190
    // Find team by name (case-insensitive)
191
3x
    for _, team := range teamResponse.Data.Teams.Nodes {
192
4x
        if strings.EqualFold(team.Name, teamIdentifier) {
193
2x
            return team.ID, nil
194
2x
        }
195
    }
196

197
1x
    return "", contextutils.NewAppError(
198
1x
        contextutils.ErrorCodeInvalidInput,
199
1x
        contextutils.SeverityError,
200
1x
        fmt.Sprintf("Team '%s' not found in Linear", teamIdentifier),
201
1x
        "",
202
1x
    )
203
}
204

205
// getProjectIDByName looks up a project ID by name within a team, or returns the ID if it's already a UUID
206
7x
func (s *LinearService) getProjectIDByName(ctx context.Context, projectIdentifier, teamID string) (string, error) {
207
7x
    // If it looks like a UUID, return it as-is (case-insensitive check)
208
7x
    if uuidRegex.MatchString(strings.ToLower(projectIdentifier)) {
209
1x
        return projectIdentifier, nil
210
1x
    }
211

212
    // Otherwise, query Linear for projects in the team
213
6x
    query := `
214
6x
        query Projects($teamId: String!) {
215
6x
            team(id: $teamId) {
216
6x
                projects {
217
6x
                    nodes {
218
6x
                        id
219
6x
                        name
220
6x
                    }
221
6x
                }
222
6x
            }
223
6x
        }
224
6x
    `
225
6x

226
6x
    variables := map[string]interface{}{
227
6x
        "teamId": teamID,
228
6x
    }
229
6x

230
6x
    requestBody := map[string]interface{}{
231
6x
        "query":     query,
232
6x
        "variables": variables,
233
6x
    }
234
6x

235
6x
    jsonData, err := json.Marshal(requestBody)
236
6x
    if err != nil {
237
        return "", contextutils.WrapError(err, "failed to marshal project lookup request")
238
    }
239

240
6x
    apiURL := s.apiURL
241
6x
    if apiURL == "" {
242
        apiURL = LinearAPIEndpoint
243
    }
244
6x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData))
245
6x
    if err != nil {
246
        return "", contextutils.WrapError(err, "failed to create project lookup request")
247
    }
248

249
6x
    req.Header.Set("Content-Type", "application/json")
250
6x
    req.Header.Set("Authorization", s.config.Linear.APIKey)
251
6x
    req.Header.Set("User-Agent", "quizapp/1.0")
252
6x

253
6x
    resp, err := s.httpClient.Do(req)
254
6x
    if err != nil {
255
        return "", contextutils.WrapErrorf(err, "failed to query Linear projects")
256
    }
257
6x
    defer func() {
258
6x
        if closeErr := resp.Body.Close(); closeErr != nil {
259
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": closeErr.Error()})
260
        }
261
    }()
262

263
6x
    body, err := io.ReadAll(resp.Body)
264
6x
    if err != nil {
265
        return "", contextutils.WrapError(err, "failed to read project lookup response")
266
    }
267

268
6x
    if resp.StatusCode != http.StatusOK {
269
1x
        return "", contextutils.NewAppError(
270
1x
            contextutils.ErrorCodeServiceUnavailable,
271
1x
            contextutils.SeverityError,
272
1x
            fmt.Sprintf("Linear API returned status %d when looking up projects: %s", resp.StatusCode, string(body)),
273
1x
            "",
274
1x
        )
275
1x
    }
276

277
5x
    var projectResponse struct {
278
5x
        Data struct {
279
5x
            Team struct {
280
5x
                Projects struct {
281
5x
                    Nodes []struct {
282
5x
                        ID   string `json:"id"`
283
5x
                        Name string `json:"name"`
284
5x
                    } `json:"nodes"`
285
5x
                } `json:"projects"`
286
5x
            } `json:"team"`
287
5x
        } `json:"data"`
288
5x
        Errors []struct {
289
5x
            Message string `json:"message"`
290
5x
        } `json:"errors,omitempty"`
291
5x
    }
292
5x

293
5x
    if err := json.Unmarshal(body, &projectResponse); err != nil {
294
1x
        return "", contextutils.WrapError(err, "failed to unmarshal project lookup response")
295
1x
    }
296

297
4x
    if len(projectResponse.Errors) > 0 {
298
1x
        return "", contextutils.NewAppError(
299
1x
            contextutils.ErrorCodeServiceUnavailable,
300
1x
            contextutils.SeverityError,
301
1x
            fmt.Sprintf("Linear API error when looking up projects: %s", projectResponse.Errors[0].Message),
302
1x
            "",
303
1x
        )
304
1x
    }
305

306
    // Find project by name (case-insensitive)
307
3x
    for _, project := range projectResponse.Data.Team.Projects.Nodes {
308
4x
        if strings.EqualFold(project.Name, projectIdentifier) {
309
3x
            return project.ID, nil
310
3x
        }
311
    }
312

313
    return "", contextutils.NewAppError(
314
        contextutils.ErrorCodeInvalidInput,
315
        contextutils.SeverityError,
316
        fmt.Sprintf("Project '%s' not found in team", projectIdentifier),
317
        "",
318
    )
319
}
320

321
// getLabelIDByName looks up a label ID by name, or returns the ID if it's already a UUID
322
7x
func (s *LinearService) getLabelIDByName(ctx context.Context, labelIdentifier string) (string, error) {
323
7x
    // If it looks like a UUID, return it as-is
324
7x
    if len(labelIdentifier) == 36 && strings.Contains(labelIdentifier, "-") {
325
1x
        return labelIdentifier, nil
326
1x
    }
327

328
    // Query Linear for both organization and team labels
329
    // First try organization-level labels (workspace-wide)
330
6x
    query := `
331
6x
        query Labels {
332
6x
            organization {
333
6x
                labels {
334
6x
                    nodes {
335
6x
                        id
336
6x
                        name
337
6x
                    }
338
6x
                }
339
6x
            }
340
6x
        }
341
6x
    `
342
6x

343
6x
    requestBody := map[string]interface{}{
344
6x
        "query": query,
345
6x
    }
346
6x

347
6x
    jsonData, err := json.Marshal(requestBody)
348
6x
    if err != nil {
349
        return "", contextutils.WrapError(err, "failed to marshal label lookup request")
350
    }
351

352
6x
    apiURL := s.apiURL
353
6x
    if apiURL == "" {
354
        apiURL = LinearAPIEndpoint
355
    }
356
6x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData))
357
6x
    if err != nil {
358
        return "", contextutils.WrapError(err, "failed to create label lookup request")
359
    }
360

361
6x
    req.Header.Set("Content-Type", "application/json")
362
6x
    req.Header.Set("Authorization", s.config.Linear.APIKey)
363
6x
    req.Header.Set("User-Agent", "quizapp/1.0")
364
6x

365
6x
    resp, err := s.httpClient.Do(req)
366
6x
    if err != nil {
367
        return "", contextutils.WrapErrorf(err, "failed to query Linear labels")
368
    }
369
6x
    defer func() {
370
6x
        if closeErr := resp.Body.Close(); closeErr != nil {
371
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": closeErr.Error()})
372
        }
373
    }()
374

375
6x
    body, err := io.ReadAll(resp.Body)
376
6x
    if err != nil {
377
        return "", contextutils.WrapError(err, "failed to read label lookup response")
378
    }
379

380
6x
    if resp.StatusCode != http.StatusOK {
381
1x
        return "", contextutils.NewAppError(
382
1x
            contextutils.ErrorCodeServiceUnavailable,
383
1x
            contextutils.SeverityError,
384
1x
            fmt.Sprintf("Linear API returned status %d when looking up labels: %s", resp.StatusCode, string(body)),
385
1x
            "",
386
1x
        )
387
1x
    }
388

389
5x
    var labelResponse struct {
390
5x
        Data struct {
391
5x
            Organization struct {
392
5x
                Labels struct {
393
5x
                    Nodes []struct {
394
5x
                        ID   string `json:"id"`
395
5x
                        Name string `json:"name"`
396
5x
                    } `json:"nodes"`
397
5x
                } `json:"labels"`
398
5x
            } `json:"organization"`
399
5x
        } `json:"data"`
400
5x
        Errors []struct {
401
5x
            Message string `json:"message"`
402
5x
        } `json:"errors,omitempty"`
403
5x
    }
404
5x

405
5x
    if err := json.Unmarshal(body, &labelResponse); err != nil {
406
1x
        return "", contextutils.WrapError(err, "failed to unmarshal label lookup response")
407
1x
    }
408

409
4x
    if len(labelResponse.Errors) > 0 {
410
1x
        return "", contextutils.NewAppError(
411
1x
            contextutils.ErrorCodeServiceUnavailable,
412
1x
            contextutils.SeverityError,
413
1x
            fmt.Sprintf("Linear API error when looking up labels: %s", labelResponse.Errors[0].Message),
414
1x
            "",
415
1x
        )
416
1x
    }
417

418
    // Find label by name (case-insensitive) in organization labels
419
3x
    for _, label := range labelResponse.Data.Organization.Labels.Nodes {
420
3x
        if strings.EqualFold(label.Name, labelIdentifier) {
421
            return label.ID, nil
422
        }
423
    }
424

425
    // If not found in organization labels, try team-specific labels
426
    // Note: We need the team ID to query team labels, but we don't have it here
427
    // For now, we'll return an error. In the future, we could pass teamID to this function
428
    // or query team labels separately in CreateIssue after we have the team ID
429

430
3x
    return "", contextutils.NewAppError(
431
3x
        contextutils.ErrorCodeInvalidInput,
432
3x
        contextutils.SeverityError,
433
3x
        fmt.Sprintf("Label '%s' not found in Linear workspace. Make sure the label exists at the workspace level (Settings > Workspace > Labels)", labelIdentifier),
434
3x
        "",
435
3x
    )
436
}
437

438
// getTeamLabelIDByName looks up a team-specific label ID by name
439
7x
func (s *LinearService) getTeamLabelIDByName(ctx context.Context, teamID, labelIdentifier string) (string, error) {
440
7x
    // Query Linear for team-specific labels
441
7x
    query := `
442
7x
        query TeamLabels($teamId: String!) {
443
7x
            team(id: $teamId) {
444
7x
                labels {
445
7x
                    nodes {
446
7x
                        id
447
7x
                        name
448
7x
                    }
449
7x
                }
450
7x
            }
451
7x
        }
452
7x
    `
453
7x

454
7x
    requestBody := map[string]interface{}{
455
7x
        "query": query,
456
7x
        "variables": map[string]interface{}{
457
7x
            "teamId": teamID,
458
7x
        },
459
7x
    }
460
7x

461
7x
    jsonData, err := json.Marshal(requestBody)
462
7x
    if err != nil {
463
        return "", contextutils.WrapError(err, "failed to marshal team label lookup request")
464
    }
465

466
7x
    apiURL := s.apiURL
467
7x
    if apiURL == "" {
468
        apiURL = LinearAPIEndpoint
469
    }
470
7x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData))
471
7x
    if err != nil {
472
        return "", contextutils.WrapError(err, "failed to create team label lookup request")
473
    }
474

475
7x
    req.Header.Set("Content-Type", "application/json")
476
7x
    req.Header.Set("Authorization", s.config.Linear.APIKey)
477
7x
    req.Header.Set("User-Agent", "quizapp/1.0")
478
7x

479
7x
    resp, err := s.httpClient.Do(req)
480
7x
    if err != nil {
481
        return "", contextutils.WrapErrorf(err, "failed to query Linear team labels")
482
    }
483
7x
    defer func() {
484
7x
        if closeErr := resp.Body.Close(); closeErr != nil {
485
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": closeErr.Error()})
486
        }
487
    }()
488

489
7x
    body, err := io.ReadAll(resp.Body)
490
7x
    if err != nil {
491
        return "", contextutils.WrapError(err, "failed to read team label lookup response")
492
    }
493

494
7x
    if resp.StatusCode != http.StatusOK {
495
1x
        return "", contextutils.NewAppError(
496
1x
            contextutils.ErrorCodeServiceUnavailable,
497
1x
            contextutils.SeverityError,
498
1x
            fmt.Sprintf("Linear API returned status %d when looking up team labels: %s", resp.StatusCode, string(body)),
499
1x
            "",
500
1x
        )
501
1x
    }
502

503
6x
    var labelResponse struct {
504
6x
        Data struct {
505
6x
            Team struct {
506
6x
                Labels struct {
507
6x
                    Nodes []struct {
508
6x
                        ID   string `json:"id"`
509
6x
                        Name string `json:"name"`
510
6x
                    } `json:"nodes"`
511
6x
                } `json:"labels"`
512
6x
            } `json:"team"`
513
6x
        } `json:"data"`
514
6x
        Errors []struct {
515
6x
            Message string `json:"message"`
516
6x
        } `json:"errors,omitempty"`
517
6x
    }
518
6x

519
6x
    if err := json.Unmarshal(body, &labelResponse); err != nil {
520
1x
        return "", contextutils.WrapError(err, "failed to unmarshal team label lookup response")
521
1x
    }
522

523
5x
    if len(labelResponse.Errors) > 0 {
524
1x
        return "", contextutils.NewAppError(
525
1x
            contextutils.ErrorCodeServiceUnavailable,
526
1x
            contextutils.SeverityError,
527
1x
            fmt.Sprintf("Linear API error when looking up team labels: %s", labelResponse.Errors[0].Message),
528
1x
            "",
529
1x
        )
530
1x
    }
531

532
    // Find label by name (case-insensitive)
533
4x
    for _, label := range labelResponse.Data.Team.Labels.Nodes {
534
4x
        if strings.EqualFold(label.Name, labelIdentifier) {
535
1x
            return label.ID, nil
536
1x
        }
537
    }
538

539
3x
    return "", contextutils.NewAppError(
540
3x
        contextutils.ErrorCodeInvalidInput,
541
3x
        contextutils.SeverityError,
542
3x
        fmt.Sprintf("Label '%s' not found in Linear team", labelIdentifier),
543
3x
        "",
544
3x
    )
545
}
546

547
// getProjectLabelIDByName looks up a project-specific label ID by name
548
7x
func (s *LinearService) getProjectLabelIDByName(ctx context.Context, projectID, labelIdentifier string) (string, error) {
549
7x
    // Query Linear for project-specific labels
550
7x
    query := `
551
7x
        query ProjectLabels($projectId: String!) {
552
7x
            project(id: $projectId) {
553
7x
                labels {
554
7x
                    nodes {
555
7x
                        id
556
7x
                        name
557
7x
                    }
558
7x
                }
559
7x
            }
560
7x
        }
561
7x
    `
562
7x

563
7x
    requestBody := map[string]interface{}{
564
7x
        "query": query,
565
7x
        "variables": map[string]interface{}{
566
7x
            "projectId": projectID,
567
7x
        },
568
7x
    }
569
7x

570
7x
    jsonData, err := json.Marshal(requestBody)
571
7x
    if err != nil {
572
        return "", contextutils.WrapError(err, "failed to marshal project label lookup request")
573
    }
574

575
7x
    apiURL := s.apiURL
576
7x
    if apiURL == "" {
577
        apiURL = LinearAPIEndpoint
578
    }
579
7x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData))
580
7x
    if err != nil {
581
        return "", contextutils.WrapError(err, "failed to create project label lookup request")
582
    }
583

584
7x
    req.Header.Set("Content-Type", "application/json")
585
7x
    req.Header.Set("Authorization", s.config.Linear.APIKey)
586
7x
    req.Header.Set("User-Agent", "quizapp/1.0")
587
7x

588
7x
    resp, err := s.httpClient.Do(req)
589
7x
    if err != nil {
590
        return "", contextutils.WrapErrorf(err, "failed to query Linear project labels")
591
    }
592
7x
    defer func() {
593
7x
        if closeErr := resp.Body.Close(); closeErr != nil {
594
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": closeErr.Error()})
595
        }
596
    }()
597

598
7x
    body, err := io.ReadAll(resp.Body)
599
7x
    if err != nil {
600
        return "", contextutils.WrapError(err, "failed to read project label lookup response")
601
    }
602

603
7x
    if resp.StatusCode != http.StatusOK {
604
1x
        return "", contextutils.NewAppError(
605
1x
            contextutils.ErrorCodeServiceUnavailable,
606
1x
            contextutils.SeverityError,
607
1x
            fmt.Sprintf("Linear API returned status %d when looking up project labels: %s", resp.StatusCode, string(body)),
608
1x
            "",
609
1x
        )
610
1x
    }
611

612
6x
    var labelResponse struct {
613
6x
        Data struct {
614
6x
            Project struct {
615
6x
                Labels struct {
616
6x
                    Nodes []struct {
617
6x
                        ID   string `json:"id"`
618
6x
                        Name string `json:"name"`
619
6x
                    } `json:"nodes"`
620
6x
                } `json:"labels"`
621
6x
            } `json:"project"`
622
6x
        } `json:"data"`
623
6x
        Errors []struct {
624
6x
            Message string `json:"message"`
625
6x
        } `json:"errors,omitempty"`
626
6x
    }
627
6x

628
6x
    if err := json.Unmarshal(body, &labelResponse); err != nil {
629
1x
        return "", contextutils.WrapError(err, "failed to unmarshal project label lookup response")
630
1x
    }
631

632
5x
    if len(labelResponse.Errors) > 0 {
633
1x
        return "", contextutils.NewAppError(
634
1x
            contextutils.ErrorCodeServiceUnavailable,
635
1x
            contextutils.SeverityError,
636
1x
            fmt.Sprintf("Linear API error when looking up project labels: %s", labelResponse.Errors[0].Message),
637
1x
            "",
638
1x
        )
639
1x
    }
640

641
    // Find label by name (case-insensitive)
642
4x
    for _, label := range labelResponse.Data.Project.Labels.Nodes {
643
4x
        if strings.EqualFold(label.Name, labelIdentifier) {
644
2x
            return label.ID, nil
645
2x
        }
646
    }
647

648
2x
    return "", contextutils.NewAppError(
649
2x
        contextutils.ErrorCodeInvalidInput,
650
2x
        contextutils.SeverityError,
651
2x
        fmt.Sprintf("Label '%s' not found in Linear project", labelIdentifier),
652
2x
        "",
653
2x
    )
654
}
655

656
// CreateIssue creates a new issue in Linear
657
14x
func (s *LinearService) CreateIssue(ctx context.Context, title, description, teamID, projectID string, labels []string, state string) (result *LinearIssueResult, err error) {
658
14x
    ctx, span := observability.TraceFunction(ctx, "linear", "create_issue",
659
14x
        attribute.String("linear.title", title),
660
14x
        attribute.String("linear.team_id", teamID),
661
14x
        attribute.String("linear.project_id", projectID),
662
14x
    )
663
14x
    defer observability.FinishSpan(span, &err)
664
14x

665
14x
    if !s.config.Linear.Enabled {
666
1x
        err = contextutils.NewAppError(
667
1x
            contextutils.ErrorCodeServiceUnavailable,
668
1x
            contextutils.SeverityError,
669
1x
            "Linear integration is disabled",
670
1x
            "",
671
1x
        )
672
1x
        return nil, err
673
1x
    }
674

675
13x
    if s.config.Linear.APIKey == "" {
676
1x
        err = contextutils.NewAppError(
677
1x
            contextutils.ErrorCodeServiceUnavailable,
678
1x
            contextutils.SeverityError,
679
1x
            "Linear API key is not configured",
680
1x
            "",
681
1x
        )
682
1x
        return nil, err
683
1x
    }
684

685
12x
    if teamID == "" {
686
3x
        teamID = s.config.Linear.TeamID
687
3x
        if teamID == "" {
688
1x
            err = contextutils.NewAppError(
689
1x
                contextutils.ErrorCodeInvalidInput,
690
1x
                contextutils.SeverityError,
691
1x
                "Linear team ID or name is required",
692
1x
                "",
693
1x
            )
694
1x
            return nil, err
695
1x
        }
696
    }
697

698
    // Look up team ID by name if it's not a UUID
699
11x
    actualTeamID, err := s.getTeamIDByName(ctx, teamID)
700
11x
    if err != nil {
701
1x
        return nil, err
702
1x
    }
703
10x
    teamID = actualTeamID
704
10x

705
10x
    // Use default project ID if none provided and resolve it
706
10x
    actualProjectID := projectID
707
10x
    if actualProjectID == "" {
708
9x
        actualProjectID = s.config.Linear.ProjectID
709
9x
    }
710

711
    // Look up project ID by name if provided and not a UUID (needed for project label lookup)
712
10x
    if actualProjectID != "" {
713
3x
        resolvedProjectID, err := s.getProjectIDByName(ctx, actualProjectID, teamID)
714
3x
        if err != nil {
715
1x
            // If project lookup fails, log warning but continue without project
716
1x
            s.logger.Warn(ctx, "Failed to look up Linear project, continuing without project", map[string]interface{}{
717
1x
                "project_identifier": actualProjectID,
718
1x
                "error":              err.Error(),
719
1x
            })
720
1x
            actualProjectID = "" // Don't include project if lookup failed
721
1x
        } else {
722
2x
            actualProjectID = resolvedProjectID
723
2x
        }
724
    }
725

726
    // Look up label IDs by name if provided
727
    // Try organization labels first, then team labels, then project labels
728
10x
    var labelIDs []string
729
10x
    if len(labels) > 0 {
730
1x
        for _, labelName := range labels {
731
1x
            labelID, err := s.getLabelIDByName(ctx, labelName)
732
1x
            if err != nil {
733
1x
                // Try team-specific labels as fallback
734
1x
                labelID, err = s.getTeamLabelIDByName(ctx, teamID, labelName)
735
1x
                if err != nil {
736
1x
                    // Try project-specific labels if project ID is available
737
1x
                    if actualProjectID != "" {
738
1x
                        labelID, err = s.getProjectLabelIDByName(ctx, actualProjectID, labelName)
739
1x
                        if err != nil {
740
1x
                            // Log warning but continue without this label
741
1x
                            s.logger.Warn(ctx, "Failed to look up Linear label (tried organization, team, and project labels), continuing without it", map[string]interface{}{
742
1x
                                "label_name": labelName,
743
1x
                                "team_id":    teamID,
744
1x
                                "project_id": actualProjectID,
745
1x
                                "error":      err.Error(),
746
1x
                            })
747
1x
                            continue
748
                        }
749
                    } else {
750
                        // Log warning but continue without this label
751
                        s.logger.Warn(ctx, "Failed to look up Linear label (tried organization and team labels), continuing without it", map[string]interface{}{
752
                            "label_name": labelName,
753
                            "team_id":    teamID,
754
                            "error":      err.Error(),
755
                        })
756
                        continue
757
                    }
758
                }
759
            }
760
            labelIDs = append(labelIDs, labelID)
761
        }
762
9x
    } else if len(s.config.Linear.DefaultLabels) > 0 {
763
2x
        // Use default labels if none provided
764
2x
        for _, labelName := range s.config.Linear.DefaultLabels {
765
2x
            labelID, err := s.getLabelIDByName(ctx, labelName)
766
2x
            if err != nil {
767
2x
                // Try team-specific labels as fallback
768
2x
                labelID, err = s.getTeamLabelIDByName(ctx, teamID, labelName)
769
2x
                if err != nil {
770
1x
                    // Try project-specific labels if project ID is available
771
1x
                    if actualProjectID != "" {
772
1x
                        labelID, err = s.getProjectLabelIDByName(ctx, actualProjectID, labelName)
773
1x
                        if err != nil {
774
                            // Log warning but continue without this label
775
                            s.logger.Warn(ctx, "Failed to look up default Linear label (tried organization, team, and project labels), continuing without it", map[string]interface{}{
776
                                "label_name": labelName,
777
                                "team_id":    teamID,
778
                                "project_id": actualProjectID,
779
                                "error":      err.Error(),
780
                            })
781
                            continue
782
                        }
783
                    } else {
784
                        // Log warning but continue without this label
785
                        s.logger.Warn(ctx, "Failed to look up default Linear label (tried organization and team labels), continuing without it", map[string]interface{}{
786
                            "label_name": labelName,
787
                            "team_id":    teamID,
788
                            "error":      err.Error(),
789
                        })
790
                        continue
791
                    }
792
                }
793
            }
794
2x
            labelIDs = append(labelIDs, labelID)
795
        }
796
    }
797

798
    // Use default state if none provided
799
    // Note: State is not yet implemented (requires fetching state ID from Linear)
800
10x
    if state == "" {
801
10x
        _ = s.config.Linear.DefaultState // Will be used when state ID lookup is implemented
802
10x
    }
803

804
10x
    projectID = actualProjectID
805
10x

806
10x
    // Build GraphQL mutation
807
10x
    // Required fields: teamId, title
808
10x
    // Optional fields: description, projectId, assigneeId, labelIds (array of IDs), stateId (ID, not name)
809
10x
    mutation := `
810
10x
        mutation IssueCreate($input: IssueCreateInput!) {
811
10x
            issueCreate(input: $input) {
812
10x
                success
813
10x
                issue {
814
10x
                    id
815
10x
                    title
816
10x
                    url
817
10x
                }
818
10x
            }
819
10x
        }
820
10x
    `
821
10x

822
10x
    input := map[string]interface{}{
823
10x
        "title":  title,
824
10x
        "teamId": teamID,
825
10x
    }
826
10x

827
10x
    // Only add description if it's not empty (Linear may reject empty strings)
828
10x
    if description != "" {
829
10x
        input["description"] = description
830
10x
    }
831

832
    // Add project ID if provided (Linear accepts projectId as UUID or name)
833
    // Note: Linear expects projectId to be a valid UUID or identifier
834
10x
    if projectID != "" {
835
2x
        input["projectId"] = projectID
836
2x
    }
837

838
    // Add label IDs if any were resolved
839
10x
    if len(labelIDs) > 0 {
840
2x
        input["labelIds"] = labelIDs
841
2x
    }
842

843
10x
    variables := map[string]interface{}{
844
10x
        "input": input,
845
10x
    }
846
10x

847
10x
    requestBody := map[string]interface{}{
848
10x
        "query":     mutation,
849
10x
        "variables": variables,
850
10x
    }
851
10x

852
10x
    jsonData, err := json.Marshal(requestBody)
853
10x
    if err != nil {
854
        span.SetAttributes(attribute.String("error", err.Error()))
855
        return nil, contextutils.WrapError(err, "failed to marshal GraphQL request")
856
    }
857

858
10x
    apiURL := s.apiURL
859
10x
    if apiURL == "" {
860
        apiURL = LinearAPIEndpoint
861
    }
862
10x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData))
863
10x
    if err != nil {
864
        span.SetAttributes(attribute.String("error", err.Error()))
865
        return nil, contextutils.WrapError(err, "failed to create HTTP request")
866
    }
867

868
10x
    req.Header.Set("Content-Type", "application/json")
869
10x
    // Personal API keys should NOT use "Bearer" prefix per Linear docs
870
10x
    // OAuth2 tokens use "Bearer" prefix, but personal API keys use the key directly
871
10x
    req.Header.Set("Authorization", s.config.Linear.APIKey)
872
10x
    req.Header.Set("User-Agent", "quizapp/1.0")
873
10x

874
10x
    startTime := time.Now()
875
10x
    resp, err := s.httpClient.Do(req)
876
10x
    duration := time.Since(startTime)
877
10x

878
10x
    if err != nil {
879
1x
        s.logger.Error(ctx, "Linear HTTP request failed", err, map[string]interface{}{
880
1x
            "duration": duration.String(),
881
1x
        })
882
1x
        span.SetAttributes(
883
1x
            attribute.String("error", err.Error()),
884
1x
            attribute.String("duration", duration.String()),
885
1x
        )
886
1x
        return nil, contextutils.WrapErrorf(err, "Linear HTTP request failed after %v", duration)
887
1x
    }
888
9x
    defer func() {
889
9x
        if cerr := resp.Body.Close(); cerr != nil {
890
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{
891
                "error": cerr.Error(),
892
            })
893
        }
894
    }()
895

896
9x
    span.SetAttributes(
897
9x
        attribute.Int("http.status_code", resp.StatusCode),
898
9x
        attribute.String("duration", duration.String()),
899
9x
    )
900
9x

901
9x
    body, err := io.ReadAll(resp.Body)
902
9x
    if err != nil {
903
        span.SetAttributes(attribute.String("error", err.Error()))
904
        return nil, contextutils.WrapError(err, "failed to read response body")
905
    }
906

907
9x
    if resp.StatusCode != http.StatusOK {
908
1x
        s.logger.Error(ctx, "Linear API returned non-200 status", nil, map[string]interface{}{
909
1x
            "status_code": resp.StatusCode,
910
1x
            "body":        string(body),
911
1x
        })
912
1x
        span.SetAttributes(
913
1x
            attribute.String("error", fmt.Sprintf("Linear API returned status %d", resp.StatusCode)),
914
1x
            attribute.String("response_body", string(body)),
915
1x
        )
916
1x
        return nil, contextutils.NewAppError(
917
1x
            contextutils.ErrorCodeServiceUnavailable,
918
1x
            contextutils.SeverityError,
919
1x
            fmt.Sprintf("Linear API returned status %d: %s", resp.StatusCode, string(body)),
920
1x
            "",
921
1x
        )
922
1x
    }
923

924
8x
    var linearResp LinearIssueResponse
925
8x
    if err := json.Unmarshal(body, &linearResp); err != nil {
926
        span.SetAttributes(attribute.String("error", err.Error()))
927
        return nil, contextutils.WrapError(err, "failed to unmarshal Linear response")
928
    }
929

930
    // Check for GraphQL errors
931
8x
    if len(linearResp.Errors) > 0 {
932
1x
        errorMsg := linearResp.Errors[0].Message
933
1x
        // Log full error details including extensions which may contain validation details
934
1x
        errorDetails := make([]map[string]interface{}, len(linearResp.Errors))
935
1x
        for i, err := range linearResp.Errors {
936
1x
            errorDetails[i] = map[string]interface{}{
937
1x
                "message": err.Message,
938
1x
            }
939
1x
            if len(err.Extensions) > 0 {
940
                errorDetails[i]["extensions"] = err.Extensions
941
            }
942
1x
            if len(err.Path) > 0 {
943
                errorDetails[i]["path"] = err.Path
944
            }
945
        }
946

947
        // Build detailed error message with all error information
948
1x
        var detailedErrorMsg strings.Builder
949
1x
        detailedErrorMsg.WriteString(errorMsg)
950
1x
        if len(linearResp.Errors[0].Extensions) > 0 {
951
            detailedErrorMsg.WriteString("\nExtensions: ")
952
            extJSON, _ := json.Marshal(linearResp.Errors[0].Extensions)
953
            detailedErrorMsg.WriteString(string(extJSON))
954
        }
955
1x
        if len(linearResp.Errors[0].Path) > 0 {
956
            detailedErrorMsg.WriteString("\nPath: ")
957
            pathJSON, _ := json.Marshal(linearResp.Errors[0].Path)
958
            detailedErrorMsg.WriteString(string(pathJSON))
959
        }
960

961
1x
        s.logger.Error(ctx, "Linear GraphQL error", nil, map[string]interface{}{
962
1x
            "errors":        errorDetails,
963
1x
            "request_body":  string(jsonData), // Log the request for debugging
964
1x
            "full_response": string(body),     // Log full response for debugging
965
1x
        })
966
1x
        span.SetAttributes(attribute.String("error", detailedErrorMsg.String()))
967
1x
        return nil, contextutils.NewAppError(
968
1x
            contextutils.ErrorCodeServiceUnavailable,
969
1x
            contextutils.SeverityError,
970
1x
            detailedErrorMsg.String(),
971
1x
            "",
972
1x
        )
973
    }
974

975
7x
    if !linearResp.Data.IssueCreate.Success {
976
1x
        s.logger.Error(ctx, "Linear issue creation failed", nil, map[string]interface{}{})
977
1x
        span.SetAttributes(attribute.String("error", "Linear issue creation was not successful"))
978
1x
        return nil, contextutils.NewAppError(
979
1x
            contextutils.ErrorCodeServiceUnavailable,
980
1x
            contextutils.SeverityError,
981
1x
            "Linear issue creation was not successful",
982
1x
            "",
983
1x
        )
984
1x
    }
985

986
6x
    issue := linearResp.Data.IssueCreate.Issue
987
6x

988
6x
    // Construct the URL if not provided (Linear sometimes doesn't return it)
989
6x
    issueURL := issue.URL
990
6x
    if issueURL == "" {
991
1x
        issueURL = fmt.Sprintf("https://linear.app/issue/%s", issue.ID)
992
1x
    }
993

994
6x
    result = &LinearIssueResult{
995
6x
        IssueID:  issue.ID,
996
6x
        IssueURL: issueURL,
997
6x
        Title:    issue.Title,
998
6x
    }
999
6x

1000
6x
    s.logger.Info(ctx, "Linear issue created successfully", map[string]interface{}{
1001
6x
        "issue_id":  issue.ID,
1002
6x
        "issue_url": issueURL,
1003
6x
        "duration":  duration.String(),
1004
6x
    })
1005
6x

1006
6x
    span.SetAttributes(
1007
6x
        attribute.String("linear.issue_id", issue.ID),
1008
6x
        attribute.String("linear.issue_url", issueURL),
1009
6x
    )
1010
6x

1011
6x
    return result, nil
1012
}
1013


			
quizapp internal services worker_service.go
50.0%
Statements
1/2
1
package services
2

3
import (
4
    "fmt"
5

6
    contextutils "quizapp/internal/utils"
7
)
8

9
// NoQuestionsAvailableError is returned when no suitable questions can be found for assignment.
10
type NoQuestionsAvailableError struct {
11
    Language       string
12
    Level          string
13
    CandidateIDs   []int
14
    CandidateCount int
15
    TotalMatching  int
16
}
17

18
2x
func (e *NoQuestionsAvailableError) Error() string {
19
2x
    return fmt.Sprintf("no questions available for assignment (language=%s level=%s candidate_count=%d total_matching=%d)", e.Language, e.Level, e.CandidateCount, e.TotalMatching)
20
2x
}
21

22
// Unwrap allows errors.Is(..., contextutils.ErrNoQuestionsAvailable) to work.
23
func (e *NoQuestionsAvailableError) Unwrap() error {
24
    return contextutils.ErrNoQuestionsAvailable
25
}
26


			
quizapp internal services worker_service.go
62.2%
Statements
84/135
1
package services
2

3
import (
4
    "context"
5
    "encoding/json"
6
    "errors"
7
    "fmt"
8
    "io"
9
    "net/http"
10
    "net/url"
11
    "strings"
12

13
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
14
    "go.opentelemetry.io/otel/attribute"
15
    "go.opentelemetry.io/otel/trace"
16

17
    "quizapp/internal/config"
18
    "quizapp/internal/models"
19
    "quizapp/internal/observability"
20
    contextutils "quizapp/internal/utils"
21
)
22

23
// ErrSignupsDisabled is returned when user registration is disabled by config
24
var ErrSignupsDisabled = errors.New("user registration is currently disabled")
25

26
// OAuth sentinel errors
27
var (
28
    ErrOAuthCodeAlreadyUsed  = errors.New("authorization code has already been used")
29
    ErrOAuthClientConfig     = errors.New("OAuth client configuration error")
30
    ErrOAuthInvalidRequest   = errors.New("invalid OAuth request")
31
    ErrOAuthUnauthorized     = errors.New("OAuth client is not authorized")
32
    ErrOAuthUnsupportedGrant = errors.New("unsupported OAuth grant type")
33
)
34

35
// OAuthService handles OAuth authentication flows
36
type OAuthService struct {
37
    config           *config.Config
38
    TokenEndpoint    string // for testing/mocking
39
    UserInfoEndpoint string // for testing/mocking
40
    logger           *observability.Logger
41
}
42

43
// NewOAuthServiceWithLogger creates a new OAuth service with logger
44
7x
func NewOAuthServiceWithLogger(cfg *config.Config, logger *observability.Logger) *OAuthService {
45
7x
    return &OAuthService{
46
7x
        config:           cfg,
47
7x
        TokenEndpoint:    "https://oauth2.googleapis.com/token",
48
7x
        UserInfoEndpoint: "https://www.googleapis.com/oauth2/v2/userinfo",
49
7x
        logger:           logger,
50
7x
    }
51
7x
}
52

53
// GoogleUserInfo represents the user information returned by Google OAuth
54
type GoogleUserInfo struct {
55
    ID            string `json:"id"`
56
    Email         string `json:"email"`
57
    Name          string `json:"name"`
58
    GivenName     string `json:"given_name"`
59
    FamilyName    string `json:"family_name"`
60
    Picture       string `json:"picture"`
61
    VerifiedEmail bool   `json:"verified_email"`
62
}
63

64
// GoogleTokenResponse represents the token response from Google OAuth
65
type GoogleTokenResponse struct {
66
    AccessToken  string `json:"access_token"`
67
    TokenType    string `json:"token_type"`
68
    ExpiresIn    int    `json:"expires_in"`
69
    RefreshToken string `json:"refresh_token,omitempty"`
70
    IDToken      string `json:"id_token,omitempty"`
71
}
72

73
// GetGoogleAuthURL generates the Google OAuth authorization URL
74
1x
func (s *OAuthService) GetGoogleAuthURL(ctx context.Context, state string) string {
75
1x
    _, span := observability.TraceOAuthFunction(ctx, "get_google_auth_url",
76
1x
        attribute.String("oauth.state", state),
77
1x
        attribute.String("oauth.client_id", s.config.GoogleOAuthClientID),
78
1x
        attribute.String("oauth.redirect_url", s.config.GoogleOAuthRedirectURL),
79
1x
    )
80
1x
    defer span.End()
81
1x

82
1x
    // Debug logging
83
1x
    if s.config.GoogleOAuthClientID == "" {
84
        if s.logger != nil {
85
            s.logger.Warn(ctx, "Google OAuth client ID is not set", map[string]interface{}{"env_var": "GOOGLE_OAUTH_CLIENT_ID"})
86
        }
87
    }
88
1x
    if s.config.GoogleOAuthRedirectURL == "" {
89
        if s.logger != nil {
90
            s.logger.Warn(ctx, "Google OAuth redirect URL is not set", map[string]interface{}{"env_var": "GOOGLE_OAUTH_REDIRECT_URL"})
91
        }
92
    }
93

94
1x
    params := url.Values{}
95
1x
    params.Set("client_id", s.config.GoogleOAuthClientID)
96
1x
    params.Set("redirect_uri", s.config.GoogleOAuthRedirectURL)
97
1x
    params.Set("response_type", "code")
98
1x
    params.Set("scope", "openid email profile")
99
1x
    params.Set("state", state)
100
1x
    params.Set("access_type", "offline")
101
1x
    params.Set("prompt", "consent")
102
1x

103
1x
    return fmt.Sprintf("https://accounts.google.com/o/oauth2/v2/auth?%s", params.Encode())
104
}
105

106
// ExchangeCodeForToken exchanges the authorization code for an access token
107
2x
func (s *OAuthService) ExchangeCodeForToken(ctx context.Context, code string) (result0 *GoogleTokenResponse, err error) {
108
2x
    ctx, span := observability.TraceOAuthFunction(ctx, "exchange_code_for_token",
109
2x
        attribute.String("oauth.code", code),
110
2x
        attribute.String("oauth.token_endpoint", s.TokenEndpoint),
111
2x
    )
112
2x
    defer observability.FinishSpan(span, &err)
113
2x

114
2x
    data := url.Values{}
115
2x
    data.Set("client_id", s.config.GoogleOAuthClientID)
116
2x
    data.Set("client_secret", s.config.GoogleOAuthClientSecret)
117
2x
    data.Set("code", code)
118
2x
    data.Set("grant_type", "authorization_code")
119
2x
    data.Set("redirect_uri", s.config.GoogleOAuthRedirectURL)
120
2x

121
2x
    tokenURL := s.TokenEndpoint
122
2x
    if tokenURL == "" {
123
        tokenURL = "https://oauth2.googleapis.com/token"
124
    }
125

126
2x
    req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
127
2x
    if err != nil {
128
        span.SetAttributes(attribute.String("error", err.Error()))
129
        return nil, contextutils.WrapError(err, "failed to create token request")
130
    }
131

132
2x
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
133
2x

134
2x
    // Use instrumented HTTP client for automatic tracing with explicit span options
135
2x
    client := &http.Client{
136
2x
        Timeout: config.OAuthHTTPTimeout,
137
2x
        Transport: otelhttp.NewTransport(http.DefaultTransport,
138
2x
            otelhttp.WithSpanOptions(trace.WithSpanKind(trace.SpanKindClient)),
139
2x
        ),
140
2x
    }
141
2x
    resp, err := client.Do(req.WithContext(ctx))
142
2x
    if err != nil {
143
        span.SetAttributes(attribute.String("error", err.Error()))
144
        return nil, contextutils.WrapError(err, "failed to exchange code for token")
145
    }
146
2x
    defer func() {
147
2x
        cerr := resp.Body.Close()
148
2x
        if cerr != nil {
149
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": cerr.Error()})
150
        }
151
    }()
152

153
2x
    span.SetAttributes(attribute.Int("http.status_code", resp.StatusCode))
154
2x

155
2x
    if resp.StatusCode != http.StatusOK {
156
        body, _ := io.ReadAll(resp.Body)
157

158
        // Try to parse the error response for better error messages
159
        var errorResp struct {
160
            Error            string `json:"error"`
161
            ErrorDescription string `json:"error_description"`
162
        }
163

164
        if json.Unmarshal(body, &errorResp) == nil {
165
            span.SetAttributes(
166
                attribute.String("oauth.error", errorResp.Error),
167
                attribute.String("oauth.error_description", errorResp.ErrorDescription),
168
            )
169
            switch errorResp.Error {
170
            case "invalid_grant":
171
                return nil, contextutils.WrapErrorf(ErrOAuthCodeAlreadyUsed, "please try signing in again")
172
            case "invalid_client":
173
                return nil, contextutils.WrapError(ErrOAuthClientConfig, "")
174
            case "invalid_request":
175
                return nil, contextutils.WrapError(ErrOAuthInvalidRequest, "")
176
            case "unauthorized_client":
177
                return nil, contextutils.WrapError(ErrOAuthUnauthorized, "")
178
            case "unsupported_grant_type":
179
                return nil, contextutils.WrapError(ErrOAuthUnsupportedGrant, "")
180
            default:
181
                return nil, contextutils.WrapErrorf(contextutils.ErrOAuthProviderError, "OAuth error: %s - %s", errorResp.Error, errorResp.ErrorDescription)
182
            }
183
        }
184

185
        return nil, contextutils.WrapErrorf(contextutils.ErrOAuthProviderError, "token exchange failed with status %d: %s", resp.StatusCode, string(body))
186
    }
187

188
2x
    var tokenResp GoogleTokenResponse
189
2x
    if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
190
        span.SetAttributes(attribute.String("error", err.Error()))
191
        return nil, contextutils.WrapError(err, "failed to decode token response")
192
    }
193

194
2x
    span.SetAttributes(
195
2x
        attribute.String("oauth.token_type", tokenResp.TokenType),
196
2x
        attribute.Int("oauth.expires_in", tokenResp.ExpiresIn),
197
2x
    )
198
2x

199
2x
    return &tokenResp, nil
200
}
201

202
// GetGoogleUserInfo retrieves user information from Google using the access token
203
2x
func (s *OAuthService) GetGoogleUserInfo(ctx context.Context, accessToken string) (result0 *GoogleUserInfo, err error) {
204
2x
    ctx, span := observability.TraceOAuthFunction(ctx, "get_google_user_info",
205
2x
        attribute.String("oauth.userinfo_endpoint", s.UserInfoEndpoint),
206
2x
    )
207
2x
    defer observability.FinishSpan(span, &err)
208
2x

209
2x
    userinfoURL := s.UserInfoEndpoint
210
2x
    if userinfoURL == "" {
211
        userinfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
212
    }
213

214
2x
    req, err := http.NewRequest("GET", userinfoURL, nil)
215
2x
    if err != nil {
216
        span.SetAttributes(attribute.String("error", err.Error()))
217
        return nil, contextutils.WrapError(err, "failed to create userinfo request")
218
    }
219

220
2x
    req.Header.Set("Authorization", "Bearer "+accessToken)
221
2x

222
2x
    // Use instrumented HTTP client for automatic tracing with explicit span options
223
2x
    client := &http.Client{
224
2x
        Timeout: config.OAuthHTTPTimeout,
225
2x
        Transport: otelhttp.NewTransport(http.DefaultTransport,
226
2x
            otelhttp.WithSpanOptions(trace.WithSpanKind(trace.SpanKindClient)),
227
2x
        ),
228
2x
    }
229
2x
    resp, err := client.Do(req.WithContext(ctx))
230
2x
    if err != nil {
231
        span.SetAttributes(attribute.String("error", err.Error()))
232
        return nil, contextutils.WrapError(err, "failed to get user info")
233
    }
234
2x
    defer func() {
235
2x
        cerr := resp.Body.Close()
236
2x
        if cerr != nil {
237
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": cerr.Error()})
238
        }
239
    }()
240

241
2x
    span.SetAttributes(attribute.Int("http.status_code", resp.StatusCode))
242
2x

243
2x
    if resp.StatusCode != http.StatusOK {
244
        body, _ := io.ReadAll(resp.Body)
245
        span.SetAttributes(attribute.String("error", fmt.Sprintf("userinfo request failed with status %d: %s", resp.StatusCode, string(body))))
246
        return nil, contextutils.WrapErrorf(contextutils.ErrOAuthProviderError, "userinfo request failed with status %d: %s", resp.StatusCode, string(body))
247
    }
248

249
2x
    var userInfo GoogleUserInfo
250
2x
    if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
251
        span.SetAttributes(attribute.String("error", err.Error()))
252
        return nil, contextutils.WrapError(err, "failed to decode user info")
253
    }
254

255
2x
    span.SetAttributes(
256
2x
        attribute.String("user.email", userInfo.Email),
257
2x
        attribute.String("user.id", userInfo.ID),
258
2x
        attribute.Bool("user.verified_email", userInfo.VerifiedEmail),
259
2x
    )
260
2x

261
2x
    return &userInfo, nil
262
}
263

264
// AuthenticateGoogleUser handles the complete Google OAuth flow
265
2x
func (s *OAuthService) AuthenticateGoogleUser(ctx context.Context, code string, userService UserServiceInterface) (result0 *models.User, err error) {
266
2x
    ctx, span := observability.TraceOAuthFunction(ctx, "authenticate_google_user",
267
2x
        attribute.String("oauth.code", code),
268
2x
    )
269
2x
    defer observability.FinishSpan(span, &err)
270
2x

271
2x
    // Exchange code for token
272
2x
    tokenResp, err := s.ExchangeCodeForToken(ctx, code)
273
2x
    if err != nil {
274
        span.SetAttributes(attribute.String("error", err.Error()))
275
        return nil, contextutils.WrapError(err, "failed to exchange code for token")
276
    }
277

278
    // Get user info from Google
279
2x
    userInfo, err := s.GetGoogleUserInfo(ctx, tokenResp.AccessToken)
280
2x
    if err != nil {
281
        span.SetAttributes(attribute.String("error", err.Error()))
282
        return nil, contextutils.WrapError(err, "failed to get user info")
283
    }
284

285
2x
    span.SetAttributes(
286
2x
        attribute.String("user.email", userInfo.Email),
287
2x
        attribute.String("user.id", userInfo.ID),
288
2x
    )
289
2x

290
2x
    // Check if user exists by email
291
2x
    existingUser, err := userService.GetUserByEmail(ctx, userInfo.Email)
292
2x
    if err != nil {
293
        span.SetAttributes(attribute.String("error", err.Error()))
294
        return nil, contextutils.WrapError(err, "failed to check existing user")
295
    }
296

297
2x
    if existingUser != nil {
298
1x
        // User exists, return the user
299
1x
        span.SetAttributes(
300
1x
            attribute.Int("user.id", existingUser.ID),
301
1x
            attribute.String("auth.result", "existing_user"),
302
1x
        )
303
1x
        return existingUser, nil
304
1x
    }
305

306
    // Check if signups are disabled before creating new user
307
1x
    if s.config != nil && s.config.IsSignupDisabled() {
308
        // Check if OAuth signup is allowed via whitelist
309
        if !s.config.IsOAuthSignupAllowed(userInfo.Email) {
310
            span.SetAttributes(
311
                attribute.String("auth.result", "oauth_signup_blocked"),
312
                attribute.String("user.email", userInfo.Email),
313
            )
314
            return nil, ErrSignupsDisabled
315
        }
316
        // Allow OAuth signup for whitelisted email/domain
317
        span.SetAttributes(
318
            attribute.String("auth.result", "oauth_signup_allowed"),
319
            attribute.String("user.email", userInfo.Email),
320
        )
321
    }
322

323
    // User doesn't exist, create new user
324
    // Use email as username (we'll handle conflicts)
325
1x
    username := userInfo.Email
326
1x
    email := userInfo.Email
327
1x

328
1x
    // Check if username already exists, if so, append a number
329
1x
    counter := 1
330
1x
    for {
331
1x
        existingUser, err := userService.GetUserByUsername(ctx, username)
332
1x
        if err != nil {
333
            span.SetAttributes(attribute.String("error", err.Error()))
334
            return nil, contextutils.WrapError(err, "failed to check username availability")
335
        }
336
1x
        if existingUser == nil {
337
1x
            break
338
        }
339
        username = fmt.Sprintf("%s_%d", userInfo.Email, counter)
340
        counter++
341
    }
342

343
1x
    span.SetAttributes(
344
1x
        attribute.String("user.username", username),
345
1x
        attribute.String("user.email", email),
346
1x
        attribute.String("auth.result", "new_user"),
347
1x
    )
348
1x

349
1x
    // Create user with default settings
350
1x
    // Use email as username (we'll handle conflicts)
351
1x
    user, err := userService.CreateUserWithEmailAndTimezone(ctx, username, email, "UTC", "italian", "beginner")
352
1x
    if err != nil {
353
        span.SetAttributes(attribute.String("error", err.Error()))
354
        return nil, contextutils.WrapError(err, "failed to create user")
355
    }
356

357
1x
    span.SetAttributes(attribute.Int("user.id", user.ID))
358
1x

359
1x
    return user, nil
360
}
361


			
quizapp internal services worker_service.go
72.7%
Statements
898/1236
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "fmt"
8
    "math/rand"
9
    "strconv"
10
    "strings"
11

12
    "quizapp/internal/config"
13
    "quizapp/internal/models"
14
    "quizapp/internal/observability"
15
    contextutils "quizapp/internal/utils"
16

17
    "go.opentelemetry.io/otel/codes"
18
    "go.opentelemetry.io/otel/trace"
19
)
20

21
// QuestionServiceInterface defines the interface for question-related operations.
22
// This allows for easier mocking in tests.
23
type QuestionServiceInterface interface {
24
    SaveQuestion(ctx context.Context, question *models.Question) error
25
    AssignQuestionToUser(ctx context.Context, questionID, userID int) error
26
    GetQuestionByID(ctx context.Context, id int) (*models.Question, error)
27
    GetQuestionWithStats(ctx context.Context, id int) (*QuestionWithStats, error)
28
    GetQuestionsByFilter(ctx context.Context, userID int, language, level string, questionType models.QuestionType, limit int) ([]models.Question, error)
29
    GetNextQuestion(ctx context.Context, userID int, language, level string, qType models.QuestionType) (*QuestionWithStats, error)
30
    GetAdaptiveQuestionsForDaily(ctx context.Context, userID int, language, level string, limit int) ([]*QuestionWithStats, error)
31
    ReportQuestion(ctx context.Context, questionID, userID int, reportReason string) error
32
    GetQuestionStats(ctx context.Context) (map[string]interface{}, error)
33
    GetDetailedQuestionStats(ctx context.Context) (map[string]interface{}, error)
34
    GetRecentQuestionContentsForUser(ctx context.Context, userID, limit int) ([]string, error)
35
    GetReportedQuestions(ctx context.Context) ([]*ReportedQuestionWithUser, error)
36
    MarkQuestionAsFixed(ctx context.Context, questionID int) error
37
    UpdateQuestion(ctx context.Context, questionID int, content map[string]interface{}, correctAnswerIndex int, explanation string) error
38
    DeleteQuestion(ctx context.Context, questionID int) error
39
    GetUserQuestions(ctx context.Context, userID, limit int) ([]*models.Question, error)
40
    GetUserQuestionsWithStats(ctx context.Context, userID, limit int) ([]*QuestionWithStats, error)
41
    GetQuestionsPaginated(ctx context.Context, userID, page, pageSize int, search, typeFilter, statusFilter string) ([]*QuestionWithStats, int, error)
42
    GetAllQuestionsPaginated(ctx context.Context, page, pageSize int, search, typeFilter, statusFilter, languageFilter, levelFilter string, userID *int) ([]*QuestionWithStats, int, error)
43
    GetReportedQuestionsPaginated(ctx context.Context, page, pageSize int, search, typeFilter, languageFilter, levelFilter string) ([]*QuestionWithStats, int, error)
44
    GetReportedQuestionsStats(ctx context.Context) (map[string]interface{}, error)
45
    GetUserQuestionCount(ctx context.Context, userID int) (int, error)
46
    GetUserResponseCount(ctx context.Context, userID int) (int, error)
47
    GetRandomGlobalQuestionForUser(ctx context.Context, userID int, language, level string, qType models.QuestionType) (*QuestionWithStats, error)
48
    GetUsersForQuestion(ctx context.Context, questionID int) ([]*models.User, int, error)
49
    AssignUsersToQuestion(ctx context.Context, questionID int, userIDs []int) error
50
    UnassignUsersFromQuestion(ctx context.Context, questionID int, userIDs []int) error
51
    DB() *sql.DB
52
}
53

54
// QuestionService provides methods for question management.
55
type QuestionService struct {
56
    db              *sql.DB
57
    learningService *LearningService
58
    logger          *observability.Logger
59
    cfg             *config.Config
60
}
61

62
// Shared query constants to eliminate duplication
63
const (
64
    // questionSelectFields contains all question fields for SELECT queries
65
    questionSelectFields = `id, type, language, level, difficulty_score, content, correct_answer, explanation, created_at, status, topic_category, grammar_focus, vocabulary_domain, scenario, style_modifier, difficulty_modifier, time_context`
66
)
67

68
// scanQuestionFromRow scans a database row into a models.Question struct
69
14x
func (s *QuestionService) scanQuestionFromRow(row *sql.Row) (result0 *models.Question, err error) {
70
14x
    question := &models.Question{}
71
14x
    var contentJSON string
72
14x
    var topicCategory sql.NullString
73
14x
    var grammarFocus sql.NullString
74
14x
    var vocabularyDomain sql.NullString
75
14x
    var scenario sql.NullString
76
14x
    var styleModifier sql.NullString
77
14x
    var difficultyModifier sql.NullString
78
14x
    var timeContext sql.NullString
79
14x

80
14x
    err = row.Scan(
81
14x
        &question.ID,
82
14x
        &question.Type,
83
14x
        &question.Language,
84
14x
        &question.Level,
85
14x
        &question.DifficultyScore,
86
14x
        &contentJSON,
87
14x
        &question.CorrectAnswer,
88
14x
        &question.Explanation,
89
14x
        &question.CreatedAt,
90
14x
        &question.Status,
91
14x
        &topicCategory,
92
14x
        &grammarFocus,
93
14x
        &vocabularyDomain,
94
14x
        &scenario,
95
14x
        &styleModifier,
96
14x
        &difficultyModifier,
97
14x
        &timeContext,
98
14x
    )
99
14x
    if err != nil {
100
1x
        return nil, err
101
1x
    }
102

103
    // Set optional string fields if they have values
104
13x
    if topicCategory.Valid {
105
13x
        question.TopicCategory = topicCategory.String
106
13x
    }
107
13x
    if grammarFocus.Valid {
108
13x
        question.GrammarFocus = grammarFocus.String
109
13x
    }
110
13x
    if vocabularyDomain.Valid {
111
13x
        question.VocabularyDomain = vocabularyDomain.String
112
13x
    }
113
13x
    if scenario.Valid {
114
13x
        question.Scenario = scenario.String
115
13x
    }
116
13x
    if styleModifier.Valid {
117
13x
        question.StyleModifier = styleModifier.String
118
13x
    }
119
13x
    if difficultyModifier.Valid {
120
13x
        question.DifficultyModifier = difficultyModifier.String
121
13x
    }
122
13x
    if timeContext.Valid {
123
13x
        question.TimeContext = timeContext.String
124
13x
    }
125

126
13x
    if err := question.UnmarshalContentFromJSON(contentJSON); err != nil {
127
        return nil, err
128
    }
129

130
13x
    return question, nil
131
}
132

133
// scanQuestionFromRows scans a database rows into a models.Question struct
134
13x
func (s *QuestionService) scanQuestionFromRows(rows *sql.Rows) (result0 *models.Question, err error) {
135
13x
    question := &models.Question{}
136
13x
    var contentJSON string
137
13x
    var topicCategory sql.NullString
138
13x
    var grammarFocus sql.NullString
139
13x
    var vocabularyDomain sql.NullString
140
13x
    var scenario sql.NullString
141
13x
    var styleModifier sql.NullString
142
13x
    var difficultyModifier sql.NullString
143
13x
    var timeContext sql.NullString
144
13x

145
13x
    err = rows.Scan(
146
13x
        &question.ID,
147
13x
        &question.Type,
148
13x
        &question.Language,
149
13x
        &question.Level,
150
13x
        &question.DifficultyScore,
151
13x
        &contentJSON,
152
13x
        &question.CorrectAnswer,
153
13x
        &question.Explanation,
154
13x
        &question.CreatedAt,
155
13x
        &question.Status,
156
13x
        &topicCategory,
157
13x
        &grammarFocus,
158
13x
        &vocabularyDomain,
159
13x
        &scenario,
160
13x
        &styleModifier,
161
13x
        &difficultyModifier,
162
13x
        &timeContext,
163
13x
    )
164
13x
    if err != nil {
165
        return nil, err
166
    }
167

168
    // Set optional string fields if they have values
169
13x
    if topicCategory.Valid {
170
13x
        question.TopicCategory = topicCategory.String
171
13x
    }
172
13x
    if grammarFocus.Valid {
173
13x
        question.GrammarFocus = grammarFocus.String
174
13x
    }
175
13x
    if vocabularyDomain.Valid {
176
13x
        question.VocabularyDomain = vocabularyDomain.String
177
13x
    }
178
13x
    if scenario.Valid {
179
13x
        question.Scenario = scenario.String
180
13x
    }
181
13x
    if styleModifier.Valid {
182
13x
        question.StyleModifier = styleModifier.String
183
13x
    }
184
13x
    if difficultyModifier.Valid {
185
13x
        question.DifficultyModifier = difficultyModifier.String
186
13x
    }
187
13x
    if timeContext.Valid {
188
13x
        question.TimeContext = timeContext.String
189
13x
    }
190

191
13x
    if err := question.UnmarshalContentFromJSON(contentJSON); err != nil {
192
        return nil, err
193
    }
194

195
13x
    return question, nil
196
}
197

198
// scanQuestionBasicFromRows scans a database rows into a models.Question struct (basic fields only)
199
6x
func (s *QuestionService) scanQuestionBasicFromRows(rows *sql.Rows) (result0 *models.Question, err error) {
200
6x
    question := &models.Question{}
201
6x
    var contentJSON string
202
6x

203
6x
    err = rows.Scan(
204
6x
        &question.ID,
205
6x
        &question.Type,
206
6x
        &question.Language,
207
6x
        &question.Level,
208
6x
        &question.DifficultyScore,
209
6x
        &contentJSON,
210
6x
        &question.CorrectAnswer,
211
6x
        &question.Explanation,
212
6x
        &question.CreatedAt,
213
6x
        &question.Status,
214
6x
    )
215
6x
    if err != nil {
216
        return nil, err
217
    }
218

219
6x
    if err := question.UnmarshalContentFromJSON(contentJSON); err != nil {
220
        return nil, err
221
    }
222

223
6x
    return question, nil
224
}
225

226
// scanQuestionWithStatsFromRows scans a database rows into a QuestionWithStats struct
227
66x
func (s *QuestionService) scanQuestionWithStatsFromRows(rows *sql.Rows) (result0 *QuestionWithStats, err error) {
228
66x
    questionWithStats := &QuestionWithStats{
229
66x
        Question: &models.Question{},
230
66x
    }
231
66x
    var contentJSON string
232
66x

233
66x
    err = rows.Scan(
234
66x
        &questionWithStats.ID,
235
66x
        &questionWithStats.Type,
236
66x
        &questionWithStats.Language,
237
66x
        &questionWithStats.Level,
238
66x
        &questionWithStats.DifficultyScore,
239
66x
        &contentJSON,
240
66x
        &questionWithStats.CorrectAnswer,
241
66x
        &questionWithStats.Explanation,
242
66x
        &questionWithStats.CreatedAt,
243
66x
        &questionWithStats.Status,
244
66x
        &questionWithStats.CorrectCount,
245
66x
        &questionWithStats.IncorrectCount,
246
66x
        &questionWithStats.TotalResponses,
247
66x
        &questionWithStats.UserCount,
248
66x
    )
249
66x
    if err != nil {
250
        return nil, err
251
    }
252

253
66x
    if err := questionWithStats.UnmarshalContentFromJSON(contentJSON); err != nil {
254
        return nil, err
255
    }
256

257
66x
    return questionWithStats, nil
258
}
259

260
// scanQuestionWithStatsAndAllFieldsFromRows scans a database rows into a QuestionWithStats struct (with all fields)
261
67x
func (s *QuestionService) scanQuestionWithStatsAndAllFieldsFromRows(rows *sql.Rows) (result0 *QuestionWithStats, err error) {
262
67x
    questionWithStats := &QuestionWithStats{
263
67x
        Question: &models.Question{},
264
67x
    }
265
67x
    var contentJSON string
266
67x
    var topicCategory sql.NullString
267
67x
    var grammarFocus sql.NullString
268
67x
    var vocabularyDomain sql.NullString
269
67x
    var scenario sql.NullString
270
67x
    var styleModifier sql.NullString
271
67x
    var difficultyModifier sql.NullString
272
67x
    var timeContext sql.NullString
273
67x

274
67x
    err = rows.Scan(
275
67x
        &questionWithStats.ID,
276
67x
        &questionWithStats.Type,
277
67x
        &questionWithStats.Language,
278
67x
        &questionWithStats.Level,
279
67x
        &questionWithStats.DifficultyScore,
280
67x
        &contentJSON,
281
67x
        &questionWithStats.CorrectAnswer,
282
67x
        &questionWithStats.Explanation,
283
67x
        &questionWithStats.CreatedAt,
284
67x
        &questionWithStats.Status,
285
67x
        &topicCategory,
286
67x
        &grammarFocus,
287
67x
        &vocabularyDomain,
288
67x
        &scenario,
289
67x
        &styleModifier,
290
67x
        &difficultyModifier,
291
67x
        &timeContext,
292
67x
        &questionWithStats.CorrectCount,
293
67x
        &questionWithStats.IncorrectCount,
294
67x
        &questionWithStats.TotalResponses,
295
67x
        &questionWithStats.UserCount,
296
67x
    )
297
67x
    if err != nil {
298
        return nil, err
299
    }
300

301
    // Set optional string fields if they have values
302
67x
    if topicCategory.Valid {
303
67x
        questionWithStats.TopicCategory = topicCategory.String
304
67x
    }
305
67x
    if grammarFocus.Valid {
306
67x
        questionWithStats.GrammarFocus = grammarFocus.String
307
67x
    }
308
67x
    if vocabularyDomain.Valid {
309
67x
        questionWithStats.VocabularyDomain = vocabularyDomain.String
310
67x
    }
311
67x
    if scenario.Valid {
312
67x
        questionWithStats.Scenario = scenario.String
313
67x
    }
314
67x
    if styleModifier.Valid {
315
67x
        questionWithStats.StyleModifier = styleModifier.String
316
67x
    }
317
67x
    if difficultyModifier.Valid {
318
67x
        questionWithStats.DifficultyModifier = difficultyModifier.String
319
67x
    }
320
67x
    if timeContext.Valid {
321
67x
        questionWithStats.TimeContext = timeContext.String
322
67x
    }
323

324
67x
    if err := questionWithStats.UnmarshalContentFromJSON(contentJSON); err != nil {
325
        return nil, err
326
    }
327

328
67x
    return questionWithStats, nil
329
}
330

331
// scanQuestionWithPriorityAndStatsFromRows scans a database rows into a QuestionWithStats struct (with priority and stats)
332
3399x
func (s *QuestionService) scanQuestionWithPriorityAndStatsFromRows(rows *sql.Rows) (result0 *QuestionWithStats, err error) {
333
3399x
    questionWithStats := &QuestionWithStats{
334
3399x
        Question: &models.Question{},
335
3399x
    }
336
3399x
    var contentJSON string
337
3399x
    var priorityScore float64
338
3399x
    var timesAnswered int
339
3399x
    var lastAnsweredAt sql.NullTime
340
3399x
    var confidenceLevel sql.NullInt32
341
3399x
    var topicCategory sql.NullString
342
3399x
    var grammarFocus sql.NullString
343
3399x
    var vocabularyDomain sql.NullString
344
3399x
    var scenario sql.NullString
345
3399x
    var styleModifier sql.NullString
346
3399x
    var difficultyModifier sql.NullString
347
3399x
    var timeContext sql.NullString
348
3399x

349
3399x
    err = rows.Scan(
350
3399x
        &questionWithStats.ID,
351
3399x
        &questionWithStats.Type,
352
3399x
        &questionWithStats.Language,
353
3399x
        &questionWithStats.Level,
354
3399x
        &questionWithStats.DifficultyScore,
355
3399x
        &contentJSON,
356
3399x
        &questionWithStats.CorrectAnswer,
357
3399x
        &questionWithStats.Explanation,
358
3399x
        &questionWithStats.CreatedAt,
359
3399x
        &questionWithStats.Status,
360
3399x
        &topicCategory,
361
3399x
        &grammarFocus,
362
3399x
        &vocabularyDomain,
363
3399x
        &scenario,
364
3399x
        &styleModifier,
365
3399x
        &difficultyModifier,
366
3399x
        &timeContext,
367
3399x
        &priorityScore,
368
3399x
        &timesAnswered,
369
3399x
        &lastAnsweredAt,
370
3399x
        &questionWithStats.CorrectCount,
371
3399x
        &questionWithStats.IncorrectCount,
372
3399x
        &questionWithStats.TotalResponses,
373
3399x
        &confidenceLevel,
374
3399x
    )
375
3399x
    if err != nil {
376
        return nil, err
377
    }
378

379
    // Set optional string fields if they have values
380
3399x
    if topicCategory.Valid {
381
3359x
        questionWithStats.TopicCategory = topicCategory.String
382
3359x
    }
383
3399x
    if grammarFocus.Valid {
384
3359x
        questionWithStats.GrammarFocus = grammarFocus.String
385
3359x
    }
386
3399x
    if vocabularyDomain.Valid {
387
3359x
        questionWithStats.VocabularyDomain = vocabularyDomain.String
388
3359x
    }
389
3399x
    if scenario.Valid {
390
3359x
        questionWithStats.Scenario = scenario.String
391
3359x
    }
392
3399x
    if styleModifier.Valid {
393
3359x
        questionWithStats.StyleModifier = styleModifier.String
394
3359x
    }
395
3399x
    if difficultyModifier.Valid {
396
3359x
        questionWithStats.DifficultyModifier = difficultyModifier.String
397
3359x
    }
398
3399x
    if timeContext.Valid {
399
3359x
        questionWithStats.TimeContext = timeContext.String
400
3359x
    }
401

402
3399x
    if err := questionWithStats.UnmarshalContentFromJSON(contentJSON); err != nil {
403
        return nil, err
404
    }
405

406
    // Set confidence level if it exists
407
3399x
    if confidenceLevel.Valid {
408
        level := int(confidenceLevel.Int32)
409
        questionWithStats.ConfidenceLevel = &level
410
    }
411

412
    // Populate per-user times answered from the scanned value
413
3399x
    questionWithStats.TimesAnswered = timesAnswered
414
3399x

415
3399x
    return questionWithStats, nil
416
}
417

418
// scanQuestionWithStatsAndReportersFromRows scans a database rows into a QuestionWithStats struct (with reporter information)
419
11x
func (s *QuestionService) scanQuestionWithStatsAndReportersFromRows(rows *sql.Rows) (result0 *QuestionWithStats, err error) {
420
11x
    questionWithStats := &QuestionWithStats{
421
11x
        Question: &models.Question{},
422
11x
    }
423
11x
    var contentJSON string
424
11x
    var reporters sql.NullString
425
11x
    var reportReasons sql.NullString
426
11x
    var topicCategory sql.NullString
427
11x
    var grammarFocus sql.NullString
428
11x
    var vocabularyDomain sql.NullString
429
11x
    var scenario sql.NullString
430
11x
    var styleModifier sql.NullString
431
11x
    var difficultyModifier sql.NullString
432
11x
    var timeContext sql.NullString
433
11x

434
11x
    err = rows.Scan(
435
11x
        &questionWithStats.ID,
436
11x
        &questionWithStats.Type,
437
11x
        &questionWithStats.Language,
438
11x
        &questionWithStats.Level,
439
11x
        &questionWithStats.DifficultyScore,
440
11x
        &contentJSON,
441
11x
        &questionWithStats.CorrectAnswer,
442
11x
        &questionWithStats.Explanation,
443
11x
        &questionWithStats.CreatedAt,
444
11x
        &questionWithStats.Status,
445
11x
        &topicCategory,
446
11x
        &grammarFocus,
447
11x
        &vocabularyDomain,
448
11x
        &scenario,
449
11x
        &styleModifier,
450
11x
        &difficultyModifier,
451
11x
        &timeContext,
452
11x
        &questionWithStats.CorrectCount,
453
11x
        &questionWithStats.IncorrectCount,
454
11x
        &questionWithStats.TotalResponses,
455
11x
        &reporters,
456
11x
        &reportReasons,
457
11x
    )
458
11x
    if err != nil {
459
        return nil, err
460
    }
461

462
    // Set optional string fields if they have values
463
11x
    if topicCategory.Valid {
464
11x
        questionWithStats.TopicCategory = topicCategory.String
465
11x
    }
466
11x
    if grammarFocus.Valid {
467
11x
        questionWithStats.GrammarFocus = grammarFocus.String
468
11x
    }
469
11x
    if vocabularyDomain.Valid {
470
11x
        questionWithStats.VocabularyDomain = vocabularyDomain.String
471
11x
    }
472
11x
    if scenario.Valid {
473
11x
        questionWithStats.Scenario = scenario.String
474
11x
    }
475
11x
    if styleModifier.Valid {
476
11x
        questionWithStats.StyleModifier = styleModifier.String
477
11x
    }
478
11x
    if difficultyModifier.Valid {
479
11x
        questionWithStats.DifficultyModifier = difficultyModifier.String
480
11x
    }
481
11x
    if timeContext.Valid {
482
11x
        questionWithStats.TimeContext = timeContext.String
483
11x
    }
484

485
11x
    if err := questionWithStats.UnmarshalContentFromJSON(contentJSON); err != nil {
486
        return nil, err
487
    }
488

489
    // Store reporter information
490
11x
    if reporters.Valid && reporters.String != "" {
491
11x
        questionWithStats.Reporters = reporters.String
492
11x
    }
493

494
    // Store report reasons information
495
11x
    if reportReasons.Valid && reportReasons.String != "" {
496
11x
        questionWithStats.ReportReasons = reportReasons.String
497
11x
    }
498

499
11x
    return questionWithStats, nil
500
}
501

502
// getQuestionByQuery is a shared method for getting a question by any query
503
14x
func (s *QuestionService) getQuestionByQuery(ctx context.Context, query string, args ...interface{}) (result0 *models.Question, err error) {
504
14x
    row := s.db.QueryRowContext(ctx, query, args...)
505
14x
    var question *models.Question
506
14x
    question, err = s.scanQuestionFromRow(row)
507
14x
    if err != nil {
508
1x
        if errors.Is(err, sql.ErrNoRows) {
509
1x
            return nil, sql.ErrNoRows // Propagate sql.ErrNoRows for not found
510
1x
        }
511
        return nil, err
512
    }
513
13x
    return question, nil
514
}
515

516
// NewQuestionServiceWithLogger creates a new QuestionService instance with logger
517
73x
func NewQuestionServiceWithLogger(db *sql.DB, learningService *LearningService, cfg *config.Config, logger *observability.Logger) *QuestionService {
518
73x
    if db == nil {
519
4x
        panic("database connection cannot be nil")
520
    }
521
69x
    if logger == nil {
522
        panic("logger cannot be nil")
523
    }
524

525
69x
    return &QuestionService{
526
69x
        db:              db,
527
69x
        learningService: learningService,
528
69x
        logger:          logger,
529
69x
        cfg:             cfg,
530
69x
    }
531
}
532

533
// getDailyRepeatAvoidDays returns the configured number of days to avoid repeating
534
// questions in daily assignments. Defaults to 7 when not configured or invalid.
535
353x
func (s *QuestionService) getDailyRepeatAvoidDays() int {
536
353x
    if s.cfg != nil {
537
353x
        if days := s.cfg.Server.DailyRepeatAvoidDays; days > 0 {
538
353x
            return days
539
353x
        }
540
    }
541
    return 7
542
}
543

544
// SaveQuestion saves a question to the database
545
193x
func (s *QuestionService) SaveQuestion(ctx context.Context, question *models.Question) (err error) {
546
193x
    ctx, span := observability.TraceQuestionFunction(ctx, "save_question", observability.AttributeQuestion(question))
547
193x
    defer func() {
548
193x
        if err != nil {
549
            span.RecordError(err, trace.WithStackTrace(true))
550
            span.SetStatus(codes.Error, err.Error())
551
        }
552
193x
        span.End()
553
    }()
554

555
    // Validate question content before saving using shared validation helper
556
193x
    if err := contextutils.ValidateQuestionContent(question.Content, question.ID); err != nil {
557
        return err
558
    }
559

560
    // Make a deep copy of content before marshaling to avoid modifying the original
561
    // MarshalContentToJSON modifies the content map in place (removes correct_answer, explanation)
562
193x
    var contentCopy map[string]interface{}
563
193x
    if question.Content != nil {
564
193x
        contentCopy = make(map[string]interface{})
565
193x
        for k, v := range question.Content {
566
393x
            // Deep copy slices to avoid sharing references
567
393x
            if slice, ok := v.([]interface{}); ok {
568
                sliceCopy := make([]interface{}, len(slice))
569
                copy(sliceCopy, slice)
570
                contentCopy[k] = sliceCopy
571
            } else if slice, ok := v.([]string); ok {
572
                sliceCopy := make([]string, len(slice))
573
193x
                copy(sliceCopy, slice)
574
193x
                contentCopy[k] = sliceCopy
575
193x
            } else {
576
200x
                contentCopy[k] = v
577
200x
            }
578
        }
579
    }
580

581
193x
    var contentJSON []byte
582
193x
    if contentCopy != nil {
583
193x
        // Temporarily set Content to the copy for marshaling
584
193x
        originalContent := question.Content
585
193x
        question.Content = contentCopy
586
193x
        contentJSONStr, err := question.MarshalContentToJSON()
587
193x
        question.Content = originalContent // Restore original
588
193x
        if err != nil {
589
            return contextutils.WrapError(err, "failed to marshal question content")
590
        }
591
193x
        contentJSON = []byte(contentJSONStr)
592
    } else {
593
        contentJSON = []byte("{}")
594
    }
595

596
193x
    if question.Status == "" {
597
7x
        question.Status = models.QuestionStatusActive
598
7x
    }
599

600
193x
    query := `
601
193x
        INSERT INTO questions (type, language, level, difficulty_score, content, correct_answer, explanation, status, topic_category, grammar_focus, vocabulary_domain, scenario, style_modifier, difficulty_modifier, time_context)
602
193x
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id
603
193x
    `
604
193x

605
193x
    var id int
606
193x
    err = s.db.QueryRowContext(ctx, query,
607
193x
        question.Type,
608
193x
        question.Language,
609
193x
        question.Level,
610
193x
        question.DifficultyScore,
611
193x
        string(contentJSON),
612
193x
        question.CorrectAnswer,
613
193x
        question.Explanation,
614
193x
        question.Status,
615
193x
        question.TopicCategory,
616
193x
        question.GrammarFocus,
617
193x
        question.VocabularyDomain,
618
193x
        question.Scenario,
619
193x
        question.StyleModifier,
620
193x
        question.DifficultyModifier,
621
193x
        question.TimeContext,
622
193x
    ).Scan(&id)
623
193x
    if err != nil {
624
        return contextutils.WrapError(err, "failed to save question to database")
625
    }
626

627
193x
    question.ID = id
628
193x
    return nil
629
}
630

631
// AssignQuestionToUser assigns a question to a user
632
198x
func (s *QuestionService) AssignQuestionToUser(ctx context.Context, questionID, userID int) (err error) {
633
198x
    ctx, span := observability.TraceQuestionFunction(ctx, "assign_question_to_user", observability.AttributeQuestionID(questionID), observability.AttributeUserID(userID))
634
198x
    defer func() {
635
198x
        if err != nil {
636
            span.RecordError(err, trace.WithStackTrace(true))
637
            span.SetStatus(codes.Error, err.Error())
638
        }
639
198x
        span.End()
640
    }()
641
198x
    query := `
642
198x
        INSERT INTO user_questions (user_id, question_id)
643
198x
        VALUES ($1, $2)
644
198x
        ON CONFLICT (user_id, question_id) DO NOTHING
645
198x
    `
646
198x
    _, err = s.db.ExecContext(ctx, query, userID, questionID)
647
198x
    return contextutils.WrapError(err, "failed to assign question to user")
648
}
649

650
// GetQuestionByID retrieves a question by its ID
651
14x
func (s *QuestionService) GetQuestionByID(ctx context.Context, id int) (result0 *models.Question, err error) {
652
14x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_question_by_id", observability.AttributeQuestionID(id))
653
14x
    defer func() {
654
14x
        if err != nil {
655
1x
            span.RecordError(err, trace.WithStackTrace(true))
656
1x
            span.SetStatus(codes.Error, err.Error())
657
1x
        }
658
14x
        span.End()
659
    }()
660
14x
    query := fmt.Sprintf("SELECT %s FROM questions WHERE id = $1", questionSelectFields)
661
14x
    return s.getQuestionByQuery(ctx, query, id)
662
}
663

664
// GetQuestionWithStats retrieves a question by its ID with response statistics
665
2x
func (s *QuestionService) GetQuestionWithStats(ctx context.Context, id int) (result0 *QuestionWithStats, err error) {
666
2x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_question_with_stats", observability.AttributeQuestionID(id))
667
2x
    defer func() {
668
2x
        if err != nil {
669
            span.RecordError(err, trace.WithStackTrace(true))
670
            span.SetStatus(codes.Error, err.Error())
671
        }
672
2x
        span.End()
673
    }()
674
2x
    query := `
675
2x
        SELECT
676
2x
            q.id, q.type, q.language, q.level, q.difficulty_score,
677
2x
            q.content, q.correct_answer, q.explanation, q.created_at, q.status,
678
2x
            q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
679
2x
            COALESCE(SUM(CASE WHEN ur.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
680
2x
            COALESCE(SUM(CASE WHEN ur.is_correct = false THEN 1 ELSE 0 END), 0) as incorrect_count,
681
2x
            COALESCE(COUNT(ur.id), 0) as total_responses
682
2x
        FROM questions q
683
2x
        LEFT JOIN user_responses ur ON q.id = ur.question_id
684
2x
        WHERE q.id = $1
685
2x
        GROUP BY q.id, q.type, q.language, q.level, q.difficulty_score,
686
2x
                 q.content, q.correct_answer, q.explanation, q.created_at, q.status,
687
2x
                 q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context
688
2x
    `
689
2x

690
2x
    q := &models.Question{}
691
2x
    stats := &QuestionWithStats{Question: q}
692
2x

693
2x
    var contentJSON string
694
2x
    err = s.db.QueryRowContext(ctx, query, id).Scan(
695
2x
        &q.ID, &q.Type, &q.Language, &q.Level, &q.DifficultyScore,
696
2x
        &contentJSON, &q.CorrectAnswer, &q.Explanation, &q.CreatedAt, &q.Status,
697
2x
        &q.TopicCategory, &q.GrammarFocus, &q.VocabularyDomain, &q.Scenario, &q.StyleModifier, &q.DifficultyModifier, &q.TimeContext,
698
2x
        &stats.CorrectCount, &stats.IncorrectCount, &stats.TotalResponses,
699
2x
    )
700
2x
    if err != nil {
701
        if errors.Is(err, sql.ErrNoRows) {
702
            return nil, contextutils.ErrQuestionNotFound
703
        }
704
        return nil, contextutils.WrapError(err, "failed to get question with stats")
705
    }
706

707
    // Parse JSON content
708
2x
    if err := q.UnmarshalContentFromJSON(contentJSON); err != nil {
709
        return nil, contextutils.WrapError(err, "failed to unmarshal question content")
710
    }
711

712
2x
    return stats, nil
713
}
714

715
// GetQuestionsByFilter retrieves questions matching the specified criteria
716
8x
func (s *QuestionService) GetQuestionsByFilter(ctx context.Context, userID int, language, level string, questionType models.QuestionType, limit int) (result0 []models.Question, err error) {
717
8x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_questions_by_filter", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(questionType))
718
8x
    defer func() {
719
8x
        if err != nil {
720
            span.RecordError(err, trace.WithStackTrace(true))
721
            span.SetStatus(codes.Error, err.Error())
722
        }
723
8x
        span.End()
724
    }()
725
8x
    var query string
726
8x
    var args []interface{}
727
8x

728
8x
    if questionType == "" {
729
3x
        // Don't filter by type if questionType is empty
730
3x
        query = `
731
3x
            SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status
732
3x
            FROM questions q
733
3x
            JOIN user_questions uq ON q.id = uq.question_id
734
3x
            WHERE uq.user_id = $1 AND q.language = $2 AND q.level = $3 AND q.status = $4
735
3x
            ORDER BY RANDOM()
736
3x
            LIMIT $5
737
3x
        `
738
3x
        args = []interface{}{userID, language, level, models.QuestionStatusActive, limit}
739
3x
    } else {
740
5x
        // Filter by specific type
741
5x
        query = `
742
5x
            SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status
743
5x
            FROM questions q
744
5x
            JOIN user_questions uq ON q.id = uq.question_id
745
5x
            WHERE uq.user_id = $1 AND q.language = $2 AND q.level = $3 AND q.type = $4 AND q.status = $5
746
5x
            ORDER BY RANDOM()
747
5x
            LIMIT $6
748
5x
        `
749
5x
        args = []interface{}{userID, language, level, questionType, models.QuestionStatusActive, limit}
750
5x
    }
751

752
8x
    rows, err := s.db.QueryContext(ctx, query, args...)
753
8x
    if err != nil {
754
        return nil, contextutils.WrapError(err, "failed to query questions by filter")
755
    }
756
8x
    defer func() {
757
8x
        if err := rows.Close(); err != nil {
758
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
759
        }
760
    }()
761

762
8x
    var questions []models.Question
763
8x
    for rows.Next() {
764
6x
        question, err := s.scanQuestionBasicFromRows(rows)
765
6x
        if err != nil {
766
            return nil, contextutils.WrapError(err, "failed to scan question from rows")
767
        }
768
6x
        questions = append(questions, *question)
769
    }
770

771
8x
    return questions, nil
772
}
773

774
// ReportedQuestionWithUser represents a reported question with user information
775
type ReportedQuestionWithUser struct {
776
    *models.Question
777
    ReportedByUsername string `json:"reported_by_username"`
778
    TotalResponses     int    `json:"total_responses"`
779
}
780

781
// GetReportedQuestions retrieves all questions that have been reported as problematic
782
1x
func (s *QuestionService) GetReportedQuestions(ctx context.Context) (result0 []*ReportedQuestionWithUser, err error) {
783
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_reported_questions")
784
1x
    defer func() {
785
1x
        if err != nil {
786
            span.RecordError(err, trace.WithStackTrace(true))
787
            span.SetStatus(codes.Error, err.Error())
788
        }
789
1x
        span.End()
790
    }()
791
1x
    query := `
792
1x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status, u.username,
793
1x
               COALESCE(COUNT(ur.id), 0) as total_responses
794
1x
        FROM questions q
795
1x
        LEFT JOIN user_questions uq ON q.id = uq.question_id
796
1x
        LEFT JOIN users u ON uq.user_id = u.id
797
1x
        LEFT JOIN user_responses ur ON q.id = ur.question_id
798
1x
        WHERE q.status = $1
799
1x
        GROUP BY q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status, u.username
800
1x
        ORDER BY q.created_at DESC
801
1x
    `
802
1x

803
1x
    var rows *sql.Rows
804
1x
    rows, err = s.db.QueryContext(ctx, query, models.QuestionStatusReported)
805
1x
    if err != nil {
806
        return nil, contextutils.WrapError(err, "failed to query reported questions")
807
    }
808
1x
    defer func() {
809
1x
        if err := rows.Close(); err != nil {
810
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
811
        }
812
    }()
813

814
1x
    var questions []*ReportedQuestionWithUser
815
1x
    for rows.Next() {
816
2x
        var question models.Question
817
2x
        var reportedByUsername sql.NullString
818
2x
        var contentJSON string
819
2x
        var totalResponses int
820
2x

821
2x
        err = rows.Scan(
822
2x
            &question.ID,
823
2x
            &question.Type,
824
2x
            &question.Language,
825
2x
            &question.Level,
826
2x
            &question.DifficultyScore,
827
2x
            &contentJSON,
828
2x
            &question.CorrectAnswer,
829
2x
            &question.Explanation,
830
2x
            &question.CreatedAt,
831
2x
            &question.Status,
832
2x
            &reportedByUsername,
833
2x
            &totalResponses,
834
2x
        )
835
2x
        if err != nil {
836
            return nil, err
837
        }
838

839
2x
        if err := question.UnmarshalContentFromJSON(contentJSON); err != nil {
840
            return nil, err
841
        }
842

843
2x
        username := ""
844
2x
        if reportedByUsername.Valid {
845
2x
            username = reportedByUsername.String
846
2x
        }
847

848
2x
        reportedQuestion := &ReportedQuestionWithUser{
849
2x
            Question:           &question,
850
2x
            ReportedByUsername: username,
851
2x
            TotalResponses:     totalResponses,
852
2x
        }
853
2x

854
2x
        questions = append(questions, reportedQuestion)
855
    }
856

857
1x
    return questions, nil
858
}
859

860
// MarkQuestionAsFixed marks a reported question as fixed and puts it back in rotation
861
1x
func (s *QuestionService) MarkQuestionAsFixed(ctx context.Context, questionID int) (err error) {
862
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "mark_question_as_fixed", observability.AttributeQuestionID(questionID))
863
1x
    defer func() {
864
1x
        if err != nil {
865
            span.RecordError(err, trace.WithStackTrace(true))
866
            span.SetStatus(codes.Error, err.Error())
867
        }
868
1x
        span.End()
869
    }()
870

871
1x
    query := `UPDATE questions SET status = $1 WHERE id = $2`
872
1x
    var result sql.Result
873
1x
    result, err = s.db.ExecContext(ctx, query, models.QuestionStatusActive, questionID)
874
1x
    if err != nil {
875
        return contextutils.WrapError(err, "failed to mark question as fixed")
876
    }
877

878
    // Check if the question was actually updated
879
1x
    rowsAffected, err := result.RowsAffected()
880
1x
    if err != nil {
881
        return contextutils.WrapError(err, "failed to get rows affected")
882
    }
883

884
1x
    if rowsAffected == 0 {
885
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with ID %d not found", questionID)
886
    }
887

888
1x
    return nil
889
}
890

891
// UpdateQuestion updates a question's content, correct answer, and explanation
892
1x
func (s *QuestionService) UpdateQuestion(ctx context.Context, questionID int, content map[string]interface{}, correctAnswerIndex int, explanation string) (err error) {
893
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "update_question", observability.AttributeQuestionID(questionID))
894
1x
    defer func() {
895
1x
        if err != nil {
896
            span.RecordError(err, trace.WithStackTrace(true))
897
            span.SetStatus(codes.Error, err.Error())
898
        }
899
1x
        span.End()
900
    }()
901

902
    // Validate question content before updating using shared validation helper
903
1x
    if err := contextutils.ValidateQuestionContent(content, questionID); err != nil {
904
        return err
905
    }
906

907
1x
    var contentJSON []byte
908
1x
    // Marshal provided content map via a temporary Question instance to reuse method
909
1x
    // Note: MarshalContentToJSON modifies the content map in place, but since this is a request
910
1x
    // payload that won't be reused, that's acceptable here
911
1x
    tempQ := &models.Question{Content: content}
912
1x
    contentJSONStr, err := tempQ.MarshalContentToJSON()
913
1x
    if err != nil {
914
        return contextutils.WrapError(err, "failed to marshal content JSON")
915
    }
916
1x
    contentJSON = []byte(contentJSONStr)
917
1x

918
1x
    query := `UPDATE questions SET content = $1, correct_answer = $2, explanation = $3 WHERE id = $4`
919
1x
    var result sql.Result
920
1x
    result, err = s.db.ExecContext(ctx, query, string(contentJSON), correctAnswerIndex, explanation, questionID)
921
1x
    if err != nil {
922
        return contextutils.WrapError(err, "failed to update question")
923
    }
924

925
    // Check if the question was actually updated
926
1x
    rowsAffected, err := result.RowsAffected()
927
1x
    if err != nil {
928
        return contextutils.WrapError(err, "failed to get rows affected")
929
    }
930

931
1x
    if rowsAffected == 0 {
932
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with ID %d not found", questionID)
933
    }
934

935
1x
    return nil
936
}
937

938
// DeleteQuestion permanently deletes a question from the database
939
1x
func (s *QuestionService) DeleteQuestion(ctx context.Context, questionID int) (err error) {
940
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "delete_question", observability.AttributeQuestionID(questionID))
941
1x
    defer func() {
942
1x
        if err != nil {
943
            span.RecordError(err, trace.WithStackTrace(true))
944
            span.SetStatus(codes.Error, err.Error())
945
        }
946
1x
        span.End()
947
    }()
948
    // First, delete associated user responses
949
1x
    deleteResponsesQuery := `DELETE FROM user_responses WHERE question_id = $1`
950
1x
    _, err = s.db.ExecContext(ctx, deleteResponsesQuery, questionID)
951
1x
    if err != nil {
952
        return contextutils.WrapError(err, "failed to delete associated user responses")
953
    }
954

955
    // Then delete the question itself
956
1x
    deleteQuestionQuery := `DELETE FROM questions WHERE id = $1`
957
1x
    var result sql.Result
958
1x
    result, err = s.db.ExecContext(ctx, deleteQuestionQuery, questionID)
959
1x
    if err != nil {
960
        return contextutils.WrapError(err, "failed to delete question")
961
    }
962

963
    // Check if the question was actually deleted
964
1x
    rowsAffected, err := result.RowsAffected()
965
1x
    if err != nil {
966
        return contextutils.WrapError(err, "failed to get rows affected")
967
    }
968

969
1x
    if rowsAffected == 0 {
970
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with ID %d not found", questionID)
971
    }
972

973
1x
    return nil
974
}
975

976
// ReportQuestion marks a question as reported/problematic by a specific user
977
11x
func (s *QuestionService) ReportQuestion(ctx context.Context, questionID, userID int, reportReason string) (err error) {
978
11x
    ctx, span := observability.TraceQuestionFunction(ctx, "report_question", observability.AttributeQuestionID(questionID), observability.AttributeUserID(userID))
979
11x
    defer func() {
980
11x
        if err != nil {
981
            span.RecordError(err, trace.WithStackTrace(true))
982
            span.SetStatus(codes.Error, err.Error())
983
        }
984
11x
        span.End()
985
    }()
986

987
    // Start a transaction
988
11x
    tx, err := s.db.BeginTx(ctx, nil)
989
11x
    if err != nil {
990
        return contextutils.WrapError(err, "failed to begin transaction")
991
    }
992
11x
    defer func() {
993
11x
        if err != nil {
994
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
995
                s.logger.Warn(ctx, "Failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
996
            }
997
        }
998
    }()
999

1000
    // Check if question exists first
1001
11x
    var questionExists bool
1002
11x
    err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM questions WHERE id = $1)`, questionID).Scan(&questionExists)
1003
11x
    if err != nil {
1004
        return contextutils.WrapError(err, "failed to check if question exists")
1005
    }
1006
11x
    if !questionExists {
1007
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with id %d not found", questionID)
1008
    }
1009

1010
    // Update question status to reported
1011
11x
    updateQuery := `UPDATE questions SET status = $1 WHERE id = $2`
1012
11x
    var result sql.Result
1013
11x
    result, err = tx.ExecContext(ctx, updateQuery, models.QuestionStatusReported, questionID)
1014
11x
    if err != nil {
1015
        return contextutils.WrapError(err, "failed to update question status")
1016
    }
1017

1018
    // Check if the question was actually updated
1019
11x
    rowsAffected, err := result.RowsAffected()
1020
11x
    if err != nil {
1021
        return contextutils.WrapError(err, "failed to get rows affected")
1022
    }
1023

1024
11x
    if rowsAffected == 0 {
1025
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with ID %d not found", questionID)
1026
    }
1027

1028
    // Use provided report reason or default message
1029
11x
    reason := reportReason
1030
11x
    if reason == "" {
1031
4x
        reason = "Question reported by user"
1032
4x
    }
1033

1034
    // Create or update a report record: if the same user reports the same question again,
1035
    // update the report_reason to the new value instead of doing nothing. Also update created_at
1036
    // so admin views show the time of the latest report by that user.
1037
11x
    reportQuery := `INSERT INTO question_reports (question_id, reported_by_user_id, report_reason) VALUES ($1, $2, $3) ON CONFLICT (question_id, reported_by_user_id) DO UPDATE SET report_reason = EXCLUDED.report_reason, created_at = now()`
1038
11x
    _, err = tx.ExecContext(ctx, reportQuery, questionID, userID, reason)
1039
11x
    if err != nil {
1040
        return contextutils.WrapError(err, "failed to create question report")
1041
    }
1042

1043
    // Commit the transaction
1044
11x
    err = tx.Commit()
1045
11x
    if err != nil {
1046
        return contextutils.WrapError(err, "failed to commit transaction")
1047
    }
1048

1049
11x
    return nil
1050
}
1051

1052
// GetNextQuestion gets the next question for a user based on usage count and availability
1053
206x
func (s *QuestionService) GetNextQuestion(ctx context.Context, userID int, language, level string, qType models.QuestionType) (result0 *QuestionWithStats, err error) {
1054
206x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_next_question", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(qType))
1055
206x
    defer func() {
1056
206x
        if err != nil {
1057
            span.RecordError(err, trace.WithStackTrace(true))
1058
            span.SetStatus(codes.Error, err.Error())
1059
        }
1060
206x
        span.End()
1061
    }()
1062
    // Use priority-based selection with stats included
1063
206x
    return s.getNextQuestionWithPriority(ctx, userID, language, level, qType)
1064
}
1065

1066
// getNextQuestionWithPriority implements priority-based question selection with stats
1067
206x
func (s *QuestionService) getNextQuestionWithPriority(ctx context.Context, userID int, language, level string, qType models.QuestionType) (result0 *QuestionWithStats, err error) {
1068
206x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_next_question_with_priority", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(qType))
1069
206x
    defer func() {
1070
206x
        if err != nil {
1071
            span.RecordError(err, trace.WithStackTrace(true))
1072
            span.SetStatus(codes.Error, err.Error())
1073
        }
1074
206x
        span.End()
1075
    }()
1076
    // Get user preferences
1077
206x
    var prefs *models.UserLearningPreferences
1078
206x
    prefs, err = s.learningService.GetUserLearningPreferences(ctx, userID)
1079
206x
    if err != nil {
1080
        s.logger.Warn(ctx, "Failed to get user preferences", map[string]interface{}{"user_id": userID, "error": err.Error()})
1081
        // Fall back to default preferences
1082
        prefs = s.learningService.GetDefaultLearningPreferences()
1083
    }
1084

1085
    // Get available questions with priority scores and stats
1086
206x
    var questions []*QuestionWithStats
1087
206x
    questions, err = s.getAvailableQuestionsWithPriority(ctx, userID, language, level, qType, prefs)
1088
206x
    if err != nil {
1089
        return nil, contextutils.WrapError(err, "failed to get available questions")
1090
    }
1091

1092
206x
    if len(questions) == 0 {
1093
3x
        // Fallback: try to get a random global question and assign it to the user
1094
3x
        globalQ, err := s.GetRandomGlobalQuestionForUser(ctx, userID, language, level, qType)
1095
3x
        if err != nil {
1096
            return nil, contextutils.WrapError(err, "no personalized questions, and failed to get global fallback question")
1097
        }
1098
3x
        if globalQ != nil {
1099
2x
            return globalQ, nil
1100
2x
        }
1101
1x
        return nil, nil // No questions available at all
1102
    }
1103

1104
    // Apply FreshQuestionRatio logic (NEW)
1105
203x
    selectedQuestion, err := s.selectQuestionWithFreshnessRatio(questions, prefs.FreshQuestionRatio)
1106
203x
    if err != nil {
1107
        return nil, contextutils.WrapError(err, "failed to select question with freshness ratio")
1108
    }
1109

1110
    // Return the selected question with stats (already included)
1111
203x
    return selectedQuestion, nil
1112
}
1113

1114
// GetAdaptiveQuestionsForDaily selects multiple adaptive questions for daily assignments
1115
52x
func (s *QuestionService) GetAdaptiveQuestionsForDaily(ctx context.Context, userID int, language, level string, limit int) (result0 []*QuestionWithStats, err error) {
1116
52x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_adaptive_questions_for_daily")
1117
52x
    defer func() {
1118
52x
        if err != nil {
1119
            span.RecordError(err, trace.WithStackTrace(true))
1120
            span.SetStatus(codes.Error, err.Error())
1121
        }
1122
52x
        span.End()
1123
    }()
1124

1125
    // Get user learning preferences
1126
52x
    prefs, err := s.learningService.GetUserLearningPreferences(ctx, userID)
1127
52x
    if err != nil {
1128
        s.logger.Warn(ctx, "Failed to get user learning preferences, using defaults", map[string]interface{}{
1129
            "user_id": userID, "error": err.Error(),
1130
        })
1131
        prefs = &models.UserLearningPreferences{
1132
            FreshQuestionRatio: 0.7,
1133
        }
1134
    }
1135

1136
52x
    var selectedQuestions []*QuestionWithStats
1137
52x
    selectedQuestionIDs := make(map[int]bool) // Track selected question IDs to prevent duplicates
1138
52x

1139
52x
    // Select questions across different types to provide variety
1140
52x
    questionTypes := []models.QuestionType{models.Vocabulary, models.FillInBlank, models.QuestionAnswer, models.ReadingComprehension}
1141
52x

1142
52x
    // Calculate how many questions to select from each type
1143
52x
    questionsPerType := limit / len(questionTypes)
1144
52x
    remainingQuestions := limit % len(questionTypes)
1145
52x

1146
52x
    for i, qType := range questionTypes {
1147
208x
        // Calculate how many questions to get for this type
1148
208x
        currentLimit := questionsPerType
1149
208x
        if i < remainingQuestions {
1150
44x
            currentLimit++ // Distribute remaining questions evenly
1151
44x
        }
1152

1153
208x
        if currentLimit == 0 {
1154
8x
            continue
1155
        }
1156

1157
        // Get available questions for DAILY with 2-day recent-correct exclusion
1158
200x
        questions, err := s.getAvailableQuestionsForDailyWithPriority(ctx, userID, language, level, qType, prefs)
1159
200x
        if err != nil {
1160
            s.logger.Warn(ctx, "Failed to get questions for type", map[string]interface{}{
1161
                "user_id": userID, "type": qType, "error": err.Error(),
1162
            })
1163
            continue
1164
        }
1165

1166
        // Filter out questions that have already been selected
1167
200x
        var availableQuestions []*QuestionWithStats
1168
200x
        for _, q := range questions {
1169
962x
            if !selectedQuestionIDs[q.ID] {
1170
962x
                availableQuestions = append(availableQuestions, q)
1171
962x
            }
1172
        }
1173

1174
200x
        if len(availableQuestions) == 0 {
1175
25x
            // Try to get a global fallback question for this type
1176
25x
            globalQ, err := s.GetRandomGlobalQuestionForUser(ctx, userID, language, level, qType)
1177
25x
            if err != nil {
1178
                s.logger.Warn(ctx, "Failed to get global fallback question", map[string]interface{}{
1179
                    "user_id": userID, "type": qType, "error": err.Error(),
1180
                })
1181
                continue
1182
            }
1183
25x
            if globalQ != nil && !selectedQuestionIDs[globalQ.ID] {
1184
                selectedQuestions = append(selectedQuestions, globalQ)
1185
                selectedQuestionIDs[globalQ.ID] = true
1186
                s.logger.Info(ctx, "Added global fallback question", map[string]interface{}{
1187
                    "user_id": userID, "type": qType, "question_id": globalQ.ID,
1188
                })
1189
            }
1190
25x
            continue
1191
        }
1192

1193
        // Select questions for this type using adaptive selection
1194
175x
        s.logger.Info(ctx, "Starting selection for question type", map[string]interface{}{
1195
175x
            "user_id": userID, "type": qType, "current_limit": currentLimit, "available_questions": len(availableQuestions),
1196
175x
        })
1197
175x

1198
175x
        questionsSelected := 0
1199
175x
        remainingQuestionsForType := availableQuestions
1200
175x

1201
175x
        for j := 0; j < currentLimit && len(remainingQuestionsForType) > 0; j++ {
1202
714x
            // Apply freshness ratio logic for each selection
1203
714x
            selectedQuestion, err := s.selectQuestionWithFreshnessRatio(remainingQuestionsForType, prefs.FreshQuestionRatio)
1204
714x
            if err != nil {
1205
                s.logger.Warn(ctx, "Failed to select question with freshness ratio", map[string]interface{}{
1206
                    "user_id": userID, "type": qType, "error": err.Error(),
1207
                })
1208
                // Fallback to simple random selection
1209
                if len(remainingQuestionsForType) > 0 {
1210
                    selectedQuestion = remainingQuestionsForType[rand.Intn(len(remainingQuestionsForType))]
1211
                } else {
1212
                    break
1213
                }
1214
            }
1215

1216
714x
            if selectedQuestion != nil && !selectedQuestionIDs[selectedQuestion.ID] {
1217
714x
                selectedQuestions = append(selectedQuestions, selectedQuestion)
1218
714x
                selectedQuestionIDs[selectedQuestion.ID] = true
1219
714x
                questionsSelected++
1220
714x

1221
714x
                // Remove the selected question from the remaining pool
1222
714x
                var newRemainingQuestions []*QuestionWithStats
1223
714x
                for _, q := range remainingQuestionsForType {
1224
4285x
                    if q.ID != selectedQuestion.ID {
1225
3571x
                        newRemainingQuestions = append(newRemainingQuestions, q)
1226
3571x
                    }
1227
                }
1228
714x
                remainingQuestionsForType = newRemainingQuestions
1229
714x

1230
714x
                s.logger.Info(ctx, "Successfully selected question", map[string]interface{}{
1231
714x
                    "user_id": userID, "type": qType, "iteration": j, "question_id": selectedQuestion.ID,
1232
714x
                    "total_selected": len(selectedQuestions),
1233
714x
                })
1234
            } else {
1235
                s.logger.Warn(ctx, "Failed to select question for type", map[string]interface{}{
1236
                    "user_id": userID, "type": qType, "iteration": j, "current_limit": currentLimit,
1237
                    "selected_question_nil": selectedQuestion == nil,
1238
                    "already_selected":      selectedQuestion != nil && selectedQuestionIDs[selectedQuestion.ID],
1239
                })
1240
                // Remove the question from the pool even if it was already selected
1241
                if selectedQuestion != nil {
1242
                    var newRemainingQuestions []*QuestionWithStats
1243
                    for _, q := range remainingQuestionsForType {
1244
                        if q.ID != selectedQuestion.ID {
1245
                            newRemainingQuestions = append(newRemainingQuestions, q)
1246
                        }
1247
                    }
1248
                    remainingQuestionsForType = newRemainingQuestions
1249
                }
1250
            }
1251
        }
1252

1253
        // If we didn't select enough questions for this type, try simple selection from all available questions
1254
175x
        if questionsSelected < currentLimit {
1255
56x
            s.logger.Info(ctx, "Using simple selection to fill remaining slots", map[string]interface{}{
1256
56x
                "user_id": userID, "type": qType, "questions_selected": questionsSelected, "current_limit": currentLimit,
1257
56x
            })
1258
56x

1259
56x
            // Get all questions for this type again and filter out already selected ones
1260
56x
            allQuestionsForType, err := s.getAvailableQuestionsForDailyWithPriority(ctx, userID, language, level, qType, prefs)
1261
56x
            if err == nil {
1262
56x
                for _, q := range allQuestionsForType {
1263
167x
                    if !selectedQuestionIDs[q.ID] && questionsSelected < currentLimit {
1264
                        selectedQuestions = append(selectedQuestions, q)
1265
                        selectedQuestionIDs[q.ID] = true
1266
                        questionsSelected++
1267
                    }
1268
                }
1269
            }
1270
        }
1271

1272
175x
        s.logger.Info(ctx, "Completed selection for question type", map[string]interface{}{
1273
175x
            "user_id": userID, "type": qType, "questions_selected": questionsSelected, "target": currentLimit,
1274
175x
        })
1275
    }
1276

1277
    // If we don't have enough questions, fill with random questions from any type
1278
52x
    if len(selectedQuestions) < limit {
1279
24x
        remainingNeeded := limit - len(selectedQuestions)
1280
24x
        s.logger.Info(ctx, "Not enough questions from type-based selection, using fallback", map[string]interface{}{
1281
24x
            "user_id": userID, "selected_count": len(selectedQuestions), "limit": limit, "remaining_needed": remainingNeeded,
1282
24x
        })
1283
24x

1284
24x
        // Get all available questions by trying each question type
1285
24x
        var allQuestions []*QuestionWithStats
1286
24x
        questionIDMap := make(map[int]bool) // Track seen question IDs to avoid duplicates
1287
24x

1288
24x
        for _, qType := range questionTypes {
1289
96x
            questions, err := s.getAvailableQuestionsForDailyWithPriority(ctx, userID, language, level, qType, prefs)
1290
96x
            if err == nil {
1291
96x
                for _, q := range questions {
1292
259x
                    if !questionIDMap[q.ID] && !selectedQuestionIDs[q.ID] {
1293
61x
                        allQuestions = append(allQuestions, q)
1294
61x
                        questionIDMap[q.ID] = true
1295
61x
                    }
1296
                }
1297
            }
1298
        }
1299

1300
24x
        s.logger.Info(ctx, "Fallback questions available", map[string]interface{}{
1301
24x
            "user_id": userID, "all_questions_count": len(allQuestions),
1302
24x
        })
1303
24x

1304
24x
        if len(allQuestions) > 0 {
1305
7x
            // Select random questions to fill the remaining slots
1306
7x
            for i := 0; i < remainingNeeded && i < len(allQuestions); i++ {
1307
18x
                selectedQuestion, err := s.selectQuestionWithFreshnessRatio(allQuestions, prefs.FreshQuestionRatio)
1308
18x
                if err != nil {
1309
                    s.logger.Warn(ctx, "Failed to select question with freshness ratio in fallback", map[string]interface{}{
1310
                        "user_id": userID, "error": err.Error(),
1311
                    })
1312
                    // Fallback to simple random selection
1313
                    if len(allQuestions) > 0 {
1314
                        selectedQuestion = allQuestions[rand.Intn(len(allQuestions))]
1315
                    } else {
1316
                        break
1317
                    }
1318
                }
1319

1320
18x
                if selectedQuestion != nil && !selectedQuestionIDs[selectedQuestion.ID] {
1321
18x
                    selectedQuestions = append(selectedQuestions, selectedQuestion)
1322
18x
                    selectedQuestionIDs[selectedQuestion.ID] = true
1323
18x

1324
18x
                    // Remove the selected question from the pool
1325
18x
                    var newAllQuestions []*QuestionWithStats
1326
18x
                    for _, q := range allQuestions {
1327
164x
                        if q.ID != selectedQuestion.ID {
1328
146x
                            newAllQuestions = append(newAllQuestions, q)
1329
146x
                        }
1330
                    }
1331
18x
                    allQuestions = newAllQuestions
1332
                } else if selectedQuestion != nil {
1333
                    // Remove the question from the pool even if it was already selected
1334
                    var newAllQuestions []*QuestionWithStats
1335
                    for _, q := range allQuestions {
1336
                        if q.ID != selectedQuestion.ID {
1337
                            newAllQuestions = append(newAllQuestions, q)
1338
                        }
1339
                    }
1340
                    allQuestions = newAllQuestions
1341
                }
1342
            }
1343
        }
1344
    }
1345

1346
    // Ensure we don't exceed the limit
1347
52x
    if len(selectedQuestions) > limit {
1348
        selectedQuestions = selectedQuestions[:limit]
1349
    }
1350

1351
    // Final duplicate check - this should never happen but provides extra safety
1352
52x
    finalSelectedQuestions := make([]*QuestionWithStats, 0, len(selectedQuestions))
1353
52x
    finalSelectedIDs := make(map[int]bool)
1354
52x

1355
52x
    for _, q := range selectedQuestions {
1356
732x
        if !finalSelectedIDs[q.ID] {
1357
732x
            finalSelectedQuestions = append(finalSelectedQuestions, q)
1358
732x
            finalSelectedIDs[q.ID] = true
1359
732x
        } else {
1360
            s.logger.Warn(ctx, "Duplicate question detected in final selection", map[string]interface{}{
1361
                "user_id": userID, "question_id": q.ID,
1362
            })
1363
        }
1364
    }
1365

1366
    // Interleave selected questions by type to avoid bias toward types that were
1367
    // selected earlier in the algorithm. This ensures that when callers slice the
1368
    // returned list (e.g., to meet a smaller goal), later types like
1369
    // ReadingComprehension are not systematically excluded.
1370
52x
    typeBuckets := make(map[models.QuestionType][]*QuestionWithStats)
1371
52x
    var typeOrder []models.QuestionType
1372
52x
    for _, q := range finalSelectedQuestions {
1373
732x
        if _, ok := typeBuckets[q.Type]; !ok {
1374
175x
            typeOrder = append(typeOrder, q.Type)
1375
175x
        }
1376
732x
        typeBuckets[q.Type] = append(typeBuckets[q.Type], q)
1377
    }
1378

1379
52x
    interleaved := make([]*QuestionWithStats, 0, len(finalSelectedQuestions))
1380
52x
    for len(interleaved) < len(finalSelectedQuestions) {
1381
215x
        added := false
1382
215x
        for _, t := range typeOrder {
1383
736x
            if len(typeBuckets[t]) > 0 {
1384
732x
                interleaved = append(interleaved, typeBuckets[t][0])
1385
732x
                typeBuckets[t] = typeBuckets[t][1:]
1386
732x
                added = true
1387
732x
                if len(interleaved) >= len(finalSelectedQuestions) {
1388
49x
                    break
1389
                }
1390
            }
1391
        }
1392
215x
        if !added {
1393
            break
1394
        }
1395
    }
1396
52x
    finalSelectedQuestions = interleaved
1397
52x

1398
52x
    s.logger.Info(ctx, "Selected adaptive questions for daily assignment", map[string]interface{}{
1399
52x
        "user_id":            userID,
1400
52x
        "language":           language,
1401
52x
        "level":              level,
1402
52x
        "requested_limit":    limit,
1403
52x
        "selected_count":     len(finalSelectedQuestions),
1404
52x
        "duplicates_removed": len(selectedQuestions) - len(finalSelectedQuestions),
1405
52x
    })
1406
52x

1407
52x
    return finalSelectedQuestions, nil
1408
}
1409

1410
// GetQuestionStats returns basic statistics about questions in the system
1411
1x
func (s *QuestionService) GetQuestionStats(ctx context.Context) (result0 map[string]interface{}, err error) {
1412
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_question_stats")
1413
1x
    defer func() {
1414
1x
        if err != nil {
1415
            span.RecordError(err, trace.WithStackTrace(true))
1416
            span.SetStatus(codes.Error, err.Error())
1417
        }
1418
1x
        span.End()
1419
    }()
1420
1x
    stats := make(map[string]interface{})
1421
1x

1422
1x
    // Total questions
1423
1x
    var totalQuestions int
1424
1x
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM questions").Scan(&totalQuestions)
1425
1x
    if err != nil {
1426
        return nil, contextutils.WrapError(err, "failed to get total questions count")
1427
    }
1428
1x
    stats["total_questions"] = totalQuestions
1429
1x

1430
1x
    // Questions by type
1431
1x
    typeQuery := `
1432
1x
        SELECT type, COUNT(*) as count
1433
1x
        FROM questions
1434
1x
        GROUP BY type
1435
1x
    `
1436
1x
    rows, err := s.db.QueryContext(ctx, typeQuery)
1437
1x
    if err != nil {
1438
        return nil, contextutils.WrapError(err, "failed to query questions by type")
1439
    }
1440
1x
    defer func() {
1441
1x
        if err := rows.Close(); err != nil {
1442
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1443
        }
1444
    }()
1445

1446
1x
    questionsByType := make(map[string]int)
1447
1x
    for rows.Next() {
1448
2x
        var qType string
1449
2x
        var count int
1450
2x
        if err := rows.Scan(&qType, &count); err != nil {
1451
            return nil, contextutils.WrapError(err, "failed to scan question type count")
1452
        }
1453
2x
        questionsByType[qType] = count
1454
    }
1455
1x
    stats["questions_by_type"] = questionsByType
1456
1x

1457
1x
    // Questions by level
1458
1x
    levelQuery := `
1459
1x
        SELECT level, COUNT(*) as count
1460
1x
        FROM questions
1461
1x
        GROUP BY level
1462
1x
    `
1463
1x
    rows, err = s.db.QueryContext(ctx, levelQuery)
1464
1x
    if err != nil {
1465
        return nil, contextutils.WrapError(err, "failed to query questions by level")
1466
    }
1467
1x
    defer func() {
1468
1x
        if err := rows.Close(); err != nil {
1469
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1470
        }
1471
    }()
1472

1473
1x
    questionsByLevel := make(map[string]int)
1474
1x
    for rows.Next() {
1475
1x
        var level string
1476
1x
        var count int
1477
1x
        if err := rows.Scan(&level, &count); err != nil {
1478
            return nil, err
1479
        }
1480
1x
        questionsByLevel[level] = count
1481
    }
1482
1x
    stats["questions_by_level"] = questionsByLevel
1483
1x

1484
1x
    return stats, nil
1485
}
1486

1487
// GetDetailedQuestionStats returns detailed statistics about questions
1488
1x
func (s *QuestionService) GetDetailedQuestionStats(ctx context.Context) (result0 map[string]interface{}, err error) {
1489
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_detailed_question_stats")
1490
1x
    defer func() {
1491
1x
        if err != nil {
1492
            span.RecordError(err, trace.WithStackTrace(true))
1493
            span.SetStatus(codes.Error, err.Error())
1494
        }
1495
1x
        span.End()
1496
    }()
1497
1x
    stats := make(map[string]interface{})
1498
1x

1499
1x
    // Total questions
1500
1x
    var totalQuestions int
1501
1x
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM questions").Scan(&totalQuestions)
1502
1x
    if err != nil {
1503
        return nil, err
1504
    }
1505
1x
    stats["total_questions"] = totalQuestions
1506
1x

1507
1x
    // Questions by language, level, and type combination
1508
1x
    detailQuery := `
1509
1x
        SELECT language, level, type, COUNT(*) as count
1510
1x
        FROM questions
1511
1x
        GROUP BY language, level, type
1512
1x
        ORDER BY language, level, type
1513
1x
    `
1514
1x
    rows, err := s.db.QueryContext(ctx, detailQuery)
1515
1x
    if err != nil {
1516
        return nil, err
1517
    }
1518
1x
    defer func() {
1519
1x
        if err := rows.Close(); err != nil {
1520
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1521
        }
1522
    }()
1523

1524
    // Create nested structure: language -> level -> type -> count
1525
1x
    questionsByDetail := make(map[string]map[string]map[string]int)
1526
1x
    for rows.Next() {
1527
1x
        var language, level, qType string
1528
1x
        var count int
1529
1x
        if err := rows.Scan(&language, &level, &qType, &count); err != nil {
1530
            return nil, err
1531
        }
1532

1533
1x
        if questionsByDetail[language] == nil {
1534
1x
            questionsByDetail[language] = make(map[string]map[string]int)
1535
1x
        }
1536
1x
        if questionsByDetail[language][level] == nil {
1537
1x
            questionsByDetail[language][level] = make(map[string]int)
1538
1x
        }
1539
1x
        questionsByDetail[language][level][qType] = count
1540
    }
1541
1x
    stats["questions_by_detail"] = questionsByDetail
1542
1x

1543
1x
    // Questions by language
1544
1x
    languageQuery := `
1545
1x
        SELECT language, COUNT(*) as count
1546
1x
        FROM questions
1547
1x
        GROUP BY language
1548
1x
    `
1549
1x
    rows, err = s.db.QueryContext(ctx, languageQuery)
1550
1x
    if err != nil {
1551
        return nil, err
1552
    }
1553
1x
    defer func() {
1554
1x
        if err := rows.Close(); err != nil {
1555
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1556
        }
1557
    }()
1558

1559
1x
    questionsByLanguage := make(map[string]int)
1560
1x
    for rows.Next() {
1561
1x
        var language string
1562
1x
        var count int
1563
1x
        if err := rows.Scan(&language, &count); err != nil {
1564
            return nil, err
1565
        }
1566
1x
        questionsByLanguage[language] = count
1567
    }
1568
1x
    stats["questions_by_language"] = questionsByLanguage
1569
1x

1570
1x
    // Questions by type
1571
1x
    typeQuery := `
1572
1x
        SELECT type, COUNT(*) as count
1573
1x
        FROM questions
1574
1x
        GROUP BY type
1575
1x
    `
1576
1x
    rows, err = s.db.QueryContext(ctx, typeQuery)
1577
1x
    if err != nil {
1578
        return nil, err
1579
    }
1580
1x
    defer func() {
1581
1x
        if err := rows.Close(); err != nil {
1582
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1583
        }
1584
    }()
1585

1586
1x
    questionsByType := make(map[string]int)
1587
1x
    for rows.Next() {
1588
1x
        var qType string
1589
1x
        var count int
1590
1x
        if err := rows.Scan(&qType, &count); err != nil {
1591
            return nil, err
1592
        }
1593
1x
        questionsByType[qType] = count
1594
    }
1595
1x
    stats["questions_by_type"] = questionsByType
1596
1x

1597
1x
    // Questions by level
1598
1x
    levelQuery := `
1599
1x
        SELECT level, COUNT(*) as count
1600
1x
        FROM questions
1601
1x
        GROUP BY level
1602
1x
    `
1603
1x
    rows, err = s.db.QueryContext(ctx, levelQuery)
1604
1x
    if err != nil {
1605
        return nil, err
1606
    }
1607
1x
    defer func() {
1608
1x
        if err := rows.Close(); err != nil {
1609
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1610
        }
1611
    }()
1612

1613
1x
    questionsByLevel := make(map[string]int)
1614
1x
    for rows.Next() {
1615
1x
        var level string
1616
1x
        var count int
1617
1x
        if err := rows.Scan(&level, &count); err != nil {
1618
            return nil, err
1619
        }
1620
1x
        questionsByLevel[level] = count
1621
    }
1622
1x
    stats["questions_by_level"] = questionsByLevel
1623
1x

1624
1x
    return stats, nil
1625
}
1626

1627
// GetRecentQuestionContentsForUser retrieves recent question contents for a user
1628
1x
func (s *QuestionService) GetRecentQuestionContentsForUser(ctx context.Context, userID, limit int) (result0 []string, err error) {
1629
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_recent_question_contents_for_user", observability.AttributeUserID(userID), observability.AttributeLimit(limit))
1630
1x
    defer func() {
1631
1x
        if err != nil {
1632
            span.RecordError(err, trace.WithStackTrace(true))
1633
            span.SetStatus(codes.Error, err.Error())
1634
        }
1635
1x
        span.End()
1636
    }()
1637
1x
    query := `
1638
1x
        SELECT DISTINCT q.content
1639
1x
        FROM user_responses ur
1640
1x
        JOIN questions q ON ur.question_id = q.id
1641
1x
        JOIN user_questions uq ON q.id = uq.question_id
1642
1x
        WHERE ur.user_id = $1 AND uq.user_id = $2
1643
1x
        ORDER BY q.content DESC
1644
1x
        LIMIT $3
1645
1x
    `
1646
1x

1647
1x
    var rows *sql.Rows
1648
1x
    rows, err = s.db.QueryContext(ctx, query, userID, userID, limit)
1649
1x
    if err != nil {
1650
        return []string{}, err
1651
    }
1652
1x
    defer func() {
1653
1x
        if err := rows.Close(); err != nil {
1654
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1655
        }
1656
    }()
1657

1658
1x
    var contents []string
1659
1x
    for rows.Next() {
1660
1x
        var content string
1661
1x
        if err := rows.Scan(&content); err != nil {
1662
            return []string{}, err
1663
        }
1664
1x
        contents = append(contents, content)
1665
    }
1666

1667
    // Ensure we always return an empty slice instead of nil
1668
1x
    if contents == nil {
1669
        contents = []string{}
1670
    }
1671

1672
1x
    return contents, nil
1673
}
1674

1675
// GetUserQuestions retrieves actual questions for a user (not just content)
1676
6x
func (s *QuestionService) GetUserQuestions(ctx context.Context, userID, limit int) (result0 []*models.Question, err error) {
1677
6x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_user_questions", observability.AttributeUserID(userID), observability.AttributeLimit(limit))
1678
6x
    defer func() {
1679
6x
        if err != nil {
1680
            span.RecordError(err, trace.WithStackTrace(true))
1681
            span.SetStatus(codes.Error, err.Error())
1682
        }
1683
6x
        span.End()
1684
    }()
1685
6x
    query := `
1686
6x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status, q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context
1687
6x
        FROM questions q
1688
6x
        JOIN user_questions uq ON q.id = uq.question_id
1689
6x
        WHERE uq.user_id = $1
1690
6x
        ORDER BY q.created_at DESC
1691
6x
        LIMIT $2
1692
6x
    `
1693
6x

1694
6x
    var rows *sql.Rows
1695
6x
    rows, err = s.db.QueryContext(ctx, query, userID, limit)
1696
6x
    if err != nil {
1697
        return nil, err
1698
    }
1699
6x
    defer func() {
1700
6x
        if err := rows.Close(); err != nil {
1701
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1702
        }
1703
    }()
1704

1705
6x
    var questions []*models.Question
1706
6x
    for rows.Next() {
1707
13x
        question, err := s.scanQuestionFromRows(rows)
1708
13x
        if err != nil {
1709
            return nil, err
1710
        }
1711
13x
        questions = append(questions, question)
1712
    }
1713

1714
6x
    return questions, nil
1715
}
1716

1717
// GetUserQuestionsWithStats retrieves questions for a user with response statistics
1718
4x
func (s *QuestionService) GetUserQuestionsWithStats(ctx context.Context, userID, limit int) (result0 []*QuestionWithStats, err error) {
1719
4x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_user_questions_with_stats", observability.AttributeUserID(userID), observability.AttributeLimit(limit))
1720
4x
    defer func() {
1721
4x
        if err != nil {
1722
            span.RecordError(err, trace.WithStackTrace(true))
1723
            span.SetStatus(codes.Error, err.Error())
1724
        }
1725
4x
        span.End()
1726
    }()
1727
4x
    query := `
1728
4x
        SELECT
1729
4x
            q.id, q.type, q.language, q.level, q.difficulty_score,
1730
4x
            q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1731
4x
            COALESCE(SUM(CASE WHEN ur.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
1732
4x
            COALESCE(SUM(CASE WHEN ur.is_correct = false THEN 1 ELSE 0 END), 0) as incorrect_count,
1733
4x
            COALESCE(COUNT(ur.id), 0) as total_responses,
1734
4x
            COALESCE(uq_stats.user_count, 0) as user_count
1735
4x
        FROM questions q
1736
4x
        JOIN user_questions uq ON q.id = uq.question_id
1737
4x
        LEFT JOIN user_responses ur ON q.id = ur.question_id
1738
4x
        LEFT JOIN (
1739
4x
            SELECT
1740
4x
                question_id,
1741
4x
                COUNT(*) as user_count
1742
4x
            FROM user_questions
1743
4x
            GROUP BY question_id
1744
4x
        ) uq_stats ON q.id = uq_stats.question_id
1745
4x
        WHERE uq.user_id = $1
1746
4x
        GROUP BY q.id, q.type, q.language, q.level, q.difficulty_score,
1747
4x
            q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1748
4x
            uq_stats.user_count
1749
4x
        ORDER BY q.created_at DESC
1750
4x
        LIMIT $2
1751
4x
    `
1752
4x

1753
4x
    rows, err := s.db.QueryContext(ctx, query, userID, limit)
1754
4x
    if err != nil {
1755
        return nil, err
1756
    }
1757
4x
    defer func() {
1758
4x
        if err := rows.Close(); err != nil {
1759
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1760
        }
1761
    }()
1762

1763
4x
    var questions []*QuestionWithStats
1764
4x
    for rows.Next() {
1765
66x
        questionWithStats, err := s.scanQuestionWithStatsFromRows(rows)
1766
66x
        if err != nil {
1767
            return nil, err
1768
        }
1769
66x
        questions = append(questions, questionWithStats)
1770
    }
1771

1772
4x
    if err = rows.Err(); err != nil {
1773
        return nil, err
1774
    }
1775

1776
4x
    return questions, nil
1777
}
1778

1779
// QuestionWithStats represents a question with response statistics
1780
type QuestionWithStats struct {
1781
    *models.Question
1782
    CorrectCount   int `json:"correct_count"`
1783
    IncorrectCount int `json:"incorrect_count"`
1784
    TotalResponses int `json:"total_responses"`
1785
    // TimesAnswered tracks how many times THIS user answered the question (per-user)
1786
    TimesAnswered   int    `json:"times_answered"`
1787
    UserCount       int    `json:"user_count"`
1788
    Reporters       string `json:"reporters,omitempty"`
1789
    ReportReasons   string `json:"report_reasons,omitempty"`
1790
    ConfidenceLevel *int   `json:"confidence_level,omitempty"`
1791
}
1792

1793
// GetQuestionsPaginated retrieves questions with pagination and response statistics
1794
7x
func (s *QuestionService) GetQuestionsPaginated(ctx context.Context, userID, page, pageSize int, search, typeFilter, statusFilter string) (result0 []*QuestionWithStats, result1 int, err error) {
1795
7x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_questions_paginated", observability.AttributeUserID(userID), observability.AttributePage(page), observability.AttributePageSize(pageSize), observability.AttributeSearch(search), observability.AttributeTypeFilter(typeFilter), observability.AttributeStatusFilter(statusFilter))
1796
7x
    defer func() {
1797
7x
        if err != nil {
1798
            span.RecordError(err, trace.WithStackTrace(true))
1799
            span.SetStatus(codes.Error, err.Error())
1800
        }
1801
7x
        span.End()
1802
    }()
1803

1804
    // Build WHERE clause with filters using parameterized queries
1805
7x
    whereConditions := []string{"uq.user_id = $1"}
1806
7x
    args := []interface{}{userID}
1807
7x
    argCount := 1
1808
7x

1809
7x
    // Add search filter
1810
7x
    if search != "" {
1811
1x
        argCount++
1812
1x
        whereConditions = append(whereConditions, fmt.Sprintf("(q.content::text ILIKE $%d OR q.explanation ILIKE $%d)", argCount, argCount))
1813
1x
        args = append(args, "%"+search+"%")
1814
1x
    }
1815

1816
    // Add type filter
1817
7x
    if typeFilter != "" {
1818
1x
        argCount++
1819
1x
        whereConditions = append(whereConditions, fmt.Sprintf("q.type = $%d", argCount))
1820
1x
        args = append(args, typeFilter)
1821
1x
    }
1822

1823
    // Add status filter
1824
7x
    if statusFilter != "" {
1825
1x
        argCount++
1826
1x
        whereConditions = append(whereConditions, fmt.Sprintf("q.status = $%d", argCount))
1827
1x
        args = append(args, statusFilter)
1828
1x
    }
1829

1830
    // Join all conditions
1831
7x
    whereClause := "WHERE " + strings.Join(whereConditions, " AND ")
1832
7x

1833
7x
    // First get the total count with filters
1834
7x
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM questions q JOIN user_questions uq ON q.id = uq.question_id %s", whereClause)
1835
7x
    var totalCount int
1836
7x
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount)
1837
7x
    if err != nil {
1838
        return nil, 0, err
1839
    }
1840

1841
    // Calculate offset
1842
7x
    offset := (page - 1) * pageSize
1843
7x

1844
7x
    // Build main query with pagination
1845
7x
    query := fmt.Sprintf(`
1846
7x
        SELECT
1847
7x
            q.id, q.type, q.language, q.level, q.difficulty_score,
1848
7x
            q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1849
7x
            q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
1850
7x
            COALESCE(SUM(CASE WHEN ur.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
1851
7x
            COALESCE(SUM(CASE WHEN ur.is_correct = false THEN 1 ELSE 0 END), 0) as incorrect_count,
1852
7x
            COALESCE(COUNT(ur.id), 0) as total_responses,
1853
7x
            COALESCE(uq_stats.user_count, 0) as user_count
1854
7x
        FROM questions q
1855
7x
        JOIN user_questions uq ON q.id = uq.question_id
1856
7x
        LEFT JOIN user_responses ur ON q.id = ur.question_id
1857
7x
        LEFT JOIN (
1858
7x
            SELECT
1859
7x
                question_id,
1860
7x
                COUNT(*) as user_count
1861
7x
            FROM user_questions
1862
7x
            GROUP BY question_id
1863
7x
        ) uq_stats ON q.id = uq_stats.question_id
1864
7x
        %s
1865
7x
        GROUP BY q.id, q.type, q.language, q.level, q.difficulty_score,
1866
7x
            q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1867
7x
            q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
1868
7x
            uq_stats.user_count
1869
7x
        ORDER BY q.id DESC
1870
7x
        LIMIT $%d OFFSET $%d
1871
7x
    `, whereClause, argCount+1, argCount+2)
1872
7x

1873
7x
    // Add pagination parameters
1874
7x
    args = append(args, pageSize, offset)
1875
7x

1876
7x
    rows, err := s.db.QueryContext(ctx, query, args...)
1877
7x
    if err != nil {
1878
        return nil, 0, err
1879
    }
1880
7x
    defer func() {
1881
7x
        if err := rows.Close(); err != nil {
1882
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1883
        }
1884
    }()
1885

1886
7x
    var questions []*QuestionWithStats
1887
7x
    for rows.Next() {
1888
67x
        questionWithStats, err := s.scanQuestionWithStatsAndAllFieldsFromRows(rows)
1889
67x
        if err != nil {
1890
            return nil, 0, err
1891
        }
1892
67x
        questions = append(questions, questionWithStats)
1893
    }
1894

1895
7x
    if err = rows.Err(); err != nil {
1896
        return nil, 0, err
1897
    }
1898

1899
7x
    return questions, totalCount, nil
1900
}
1901

1902
// PRIORITY-BASED QUESTION SELECTION METHODS
1903

1904
// getAvailableQuestionsWithPriority retrieves available questions with priority scores and stats
1905
206x
func (s *QuestionService) getAvailableQuestionsWithPriority(ctx context.Context, userID int, language, level string, qType models.QuestionType, _ *models.UserLearningPreferences) (result0 []*QuestionWithStats, err error) {
1906
206x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_available_questions_with_priority", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(qType))
1907
206x
    defer func() {
1908
206x
        if err != nil {
1909
            span.RecordError(err, trace.WithStackTrace(true))
1910
            span.SetStatus(codes.Error, err.Error())
1911
        }
1912
206x
        span.End()
1913
    }()
1914
    // Build SQL query with priority scoring and stats
1915
206x
    query := `
1916
206x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1917
206x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
1918
206x
               COALESCE(qps.priority_score, 100.0) as priority_score,
1919
206x
               COALESCE(uq_stats.times_answered, 0) as times_answered,
1920
206x
               uq_stats.last_answered_at,
1921
206x
               COALESCE(stats.correct_count, 0) as correct_count,
1922
206x
               COALESCE(stats.incorrect_count, 0) as incorrect_count,
1923
206x
               COALESCE(stats.total_responses, 0) as total_responses,
1924
206x
               uqm.confidence_level
1925
206x
        FROM questions q
1926
206x
        JOIN user_questions uq ON q.id = uq.question_id
1927
206x
        LEFT JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
1928
206x
        LEFT JOIN (
1929
206x
            SELECT question_id,
1930
206x
                   COUNT(*) as times_answered,
1931
206x
                   MAX(created_at) as last_answered_at
1932
206x
            FROM user_responses
1933
206x
            WHERE user_id = $1
1934
206x
            GROUP BY question_id
1935
206x
        ) uq_stats ON q.id = uq_stats.question_id
1936
206x
        LEFT JOIN (
1937
206x
            SELECT
1938
206x
                question_id,
1939
206x
                COUNT(CASE WHEN is_correct = true THEN 1 END) as correct_count,
1940
206x
                COUNT(CASE WHEN is_correct = false THEN 1 END) as incorrect_count,
1941
206x
                COUNT(*) as total_responses
1942
206x
            FROM user_responses
1943
206x
            GROUP BY question_id
1944
206x
        ) stats ON q.id = stats.question_id
1945
206x
        LEFT JOIN user_question_metadata uqm ON q.id = uqm.question_id AND uqm.user_id = $1
1946
206x
        WHERE uq.user_id = $1
1947
206x
        AND q.language = $2
1948
206x
        AND q.level = $3
1949
206x
        AND q.type = $4
1950
206x
        AND q.status = 'active'
1951
206x
        AND q.id NOT IN (
1952
206x
            SELECT ur.question_id
1953
206x
            FROM user_responses ur
1954
206x
            WHERE ur.user_id = $1
1955
206x
              AND ur.created_at > NOW() - INTERVAL '1 hour'
1956
206x
        )
1957
206x
        -- Exclude questions where the user's last 3 responses were all correct within the last 90 days
1958
206x
        AND NOT EXISTS (
1959
206x
            SELECT 1 FROM (
1960
206x
                SELECT ur2.is_correct
1961
206x
                FROM user_responses ur2
1962
206x
                WHERE ur2.user_id = $1
1963
206x
                  AND ur2.question_id = q.id
1964
206x
                  AND ur2.created_at >= NOW() - INTERVAL '90 days'
1965
206x
                ORDER BY ur2.created_at DESC
1966
206x
                LIMIT 3
1967
206x
            ) recent_three
1968
206x
            WHERE (SELECT COUNT(*) FROM (
1969
206x
                SELECT 1 FROM (
1970
206x
                    SELECT ur3.is_correct
1971
206x
                    FROM user_responses ur3
1972
206x
                    WHERE ur3.user_id = $1
1973
206x
                      AND ur3.question_id = q.id
1974
206x
                      AND ur3.created_at >= NOW() - INTERVAL '90 days'
1975
206x
                    ORDER BY ur3.created_at DESC
1976
206x
                    LIMIT 3
1977
206x
                ) t WHERE t.is_correct = TRUE
1978
206x
            ) c) = 3
1979
206x
        )
1980
206x
        -- Exclude questions the user explicitly marked as known with max confidence (5)
1981
206x
        -- within the last 60 days (approx. 2 months)
1982
206x
        AND NOT EXISTS (
1983
206x
            SELECT 1 FROM user_question_metadata uqm2
1984
206x
            WHERE uqm2.user_id = $1
1985
206x
              AND uqm2.question_id = q.id
1986
206x
              AND uqm2.marked_as_known = TRUE
1987
206x
              AND uqm2.confidence_level = 5
1988
206x
              AND uqm2.marked_as_known_at >= NOW() - INTERVAL '60 days'
1989
206x
        )
1990
206x
        ORDER BY priority_score DESC, RANDOM()
1991
206x
        LIMIT 50
1992
206x
    `
1993
206x

1994
206x
    rows, err := s.db.QueryContext(ctx, query, userID, language, level, qType)
1995
206x
    if err != nil {
1996
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to query questions: %w", err)
1997
    }
1998
206x
    defer func() {
1999
206x
        if err := rows.Close(); err != nil {
2000
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2001
        }
2002
    }()
2003

2004
206x
    var questions []*QuestionWithStats
2005
206x
    for rows.Next() {
2006
2011x
        questionWithStats, err := s.scanQuestionWithPriorityAndStatsFromRows(rows)
2007
2011x
        if err != nil {
2008
            s.logger.Error(ctx, "Error scanning question", err, map[string]interface{}{})
2009
            continue // Skip malformed rows
2010
        }
2011
2011x
        questions = append(questions, questionWithStats)
2012
    }
2013

2014
206x
    return questions, nil
2015
}
2016

2017
// getAvailableQuestionsForDailyWithPriority applies daily-specific eligibility:
2018
// exclude questions answered correctly within the last 2 days for the user.
2019
352x
func (s *QuestionService) getAvailableQuestionsForDailyWithPriority(ctx context.Context, userID int, language, level string, qType models.QuestionType, _ *models.UserLearningPreferences) (result0 []*QuestionWithStats, err error) {
2020
352x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_available_questions_for_daily_with_priority", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(qType))
2021
352x
    defer func() {
2022
352x
        if err != nil {
2023
            span.RecordError(err, trace.WithStackTrace(true))
2024
            span.SetStatus(codes.Error, err.Error())
2025
        }
2026
352x
        span.End()
2027
    }()
2028
352x
    avoidDays := s.getDailyRepeatAvoidDays()
2029
352x
    query := `
2030
352x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
2031
352x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
2032
352x
               COALESCE(qps.priority_score, 100.0) as priority_score,
2033
352x
               COALESCE(uq_stats.times_answered, 0) as times_answered,
2034
352x
               uq_stats.last_answered_at,
2035
352x
               COALESCE(stats.correct_count, 0) as correct_count,
2036
352x
               COALESCE(stats.incorrect_count, 0) as incorrect_count,
2037
352x
               COALESCE(stats.total_responses, 0) as total_responses,
2038
352x
               uqm.confidence_level
2039
352x
        FROM questions q
2040
352x
        JOIN user_questions uq ON q.id = uq.question_id
2041
352x
        LEFT JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
2042
352x
        LEFT JOIN (
2043
352x
            SELECT question_id,
2044
352x
                   COUNT(*) as times_answered,
2045
352x
                   MAX(created_at) as last_answered_at
2046
352x
            FROM user_responses
2047
352x
            WHERE user_id = $1
2048
352x
            GROUP BY question_id
2049
352x
        ) uq_stats ON q.id = uq_stats.question_id
2050
352x
        LEFT JOIN (
2051
352x
            SELECT
2052
352x
                question_id,
2053
352x
                COUNT(CASE WHEN is_correct = true THEN 1 END) as correct_count,
2054
352x
                COUNT(CASE WHEN is_correct = false THEN 1 END) as incorrect_count,
2055
352x
                COUNT(*) as total_responses
2056
352x
            FROM user_responses
2057
352x
            GROUP BY question_id
2058
352x
        ) stats ON q.id = stats.question_id
2059
352x
        LEFT JOIN user_question_metadata uqm ON q.id = uqm.question_id AND uqm.user_id = $1
2060
352x
        WHERE uq.user_id = $1
2061
352x
        AND q.language = $2
2062
352x
        AND q.level = $3
2063
352x
        AND q.type = $4
2064
352x
        AND q.status = 'active'
2065
352x
        AND NOT EXISTS (
2066
352x
            SELECT 1
2067
352x
            FROM user_responses ur
2068
352x
            WHERE ur.user_id = $1
2069
352x
              AND ur.question_id = q.id
2070
352x
              AND ur.is_correct = TRUE
2071
352x
              AND ur.created_at >= NOW() - ($5 || ' days')::interval
2072
352x
        )
2073
352x
        -- Exclude questions the user marked as known with confidence 5 within last 60 days
2074
352x
        AND NOT EXISTS (
2075
352x
            SELECT 1 FROM user_question_metadata uqm2
2076
352x
            WHERE uqm2.user_id = $1
2077
352x
              AND uqm2.question_id = q.id
2078
352x
              AND uqm2.marked_as_known = TRUE
2079
352x
              AND uqm2.confidence_level = 5
2080
352x
              AND uqm2.marked_as_known_at >= NOW() - INTERVAL '60 days'
2081
352x
        )
2082
352x
        ORDER BY priority_score DESC, RANDOM()
2083
352x
        LIMIT 50
2084
352x
    `
2085
352x

2086
352x
    rows, err := s.db.QueryContext(ctx, query, userID, language, level, qType, avoidDays)
2087
352x
    if err != nil {
2088
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to query questions (daily): %w", err)
2089
    }
2090
352x
    defer func() {
2091
352x
        if err := rows.Close(); err != nil {
2092
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2093
        }
2094
    }()
2095

2096
352x
    var questions []*QuestionWithStats
2097
352x
    for rows.Next() {
2098
1388x
        questionWithStats, err := s.scanQuestionWithPriorityAndStatsFromRows(rows)
2099
1388x
        if err != nil {
2100
            s.logger.Error(ctx, "Error scanning question (daily)", err, map[string]interface{}{})
2101
            continue
2102
        }
2103
1388x
        questions = append(questions, questionWithStats)
2104
    }
2105

2106
352x
    return questions, nil
2107
}
2108

2109
// selectQuestionWithWeightedRandomness selects a question using weighted random selection
2110
935x
func (s *QuestionService) selectQuestionWithWeightedRandomness(questions []*QuestionWithStats) (result0 *QuestionWithStats, err error) {
2111
935x
    if len(questions) == 0 {
2112
        return nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "no questions available")
2113
    }
2114

2115
    // Use weighted random selection based on usage count (lower = higher priority)
2116
935x
    totalWeight := 0.0
2117
935x
    for _, q := range questions {
2118
5414x
        // Prefer per-user times answered when available
2119
5414x
        usageCount := q.TotalResponses
2120
5414x
        if q.TimesAnswered >= 0 {
2121
5414x
            usageCount = q.TimesAnswered
2122
5414x
        }
2123
        // Lower usage count = higher weight
2124
5414x
        weight := 1.0 / (float64(usageCount) + 1.0)
2125
5414x
        totalWeight += weight
2126
    }
2127

2128
    // Handle edge case where all questions have zero weight or floating-point precision issues
2129
935x
    if totalWeight <= 0 {
2130
        // If all questions have equal weight (e.g., all TotalResponses = 0), use simple random selection
2131
        return questions[rand.Intn(len(questions))], nil
2132
    }
2133

2134
935x
    target := rand.Float64() * totalWeight
2135
935x
    currentWeight := 0.0
2136
935x

2137
935x
    for _, q := range questions {
2138
3077x
        usageCount := q.TotalResponses
2139
3077x
        if q.TimesAnswered >= 0 {
2140
3077x
            usageCount = q.TimesAnswered
2141
3077x
        }
2142
3077x
        weight := 1.0 / (float64(usageCount) + 1.0)
2143
3077x
        currentWeight += weight
2144
3077x
        if currentWeight >= target {
2145
935x
            return q, nil
2146
935x
        }
2147
    }
2148

2149
    // Fallback: if we reach the end without selecting (due to floating-point precision),
2150
    // return the last question or a random one
2151
    if len(questions) > 0 {
2152
        return questions[len(questions)-1], nil
2153
    }
2154

2155
    return nil, contextutils.WrapError(contextutils.ErrInternalError, "failed to select question with weighted randomness")
2156
}
2157

2158
// selectQuestionWithFreshnessRatio selects a question based on freshness ratio
2159
935x
func (s *QuestionService) selectQuestionWithFreshnessRatio(questions []*QuestionWithStats, freshnessRatio float64) (result0 *QuestionWithStats, err error) {
2160
935x
    if len(questions) == 0 {
2161
        return nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "no questions available")
2162
    }
2163

2164
    // Separate fresh and review questions based on total responses
2165
935x
    var freshQuestions []*QuestionWithStats
2166
935x
    var reviewQuestions []*QuestionWithStats
2167
935x

2168
935x
    for _, q := range questions {
2169
6460x
        // Consider fresh relative to this user (TimesAnswered==0). Fall back to TotalResponses if TimesAnswered not set.
2170
6460x
        isFresh := false
2171
6460x
        if q.TimesAnswered >= 0 {
2172
6460x
            isFresh = q.TimesAnswered == 0
2173
6460x
        } else {
2174
            isFresh = q.TotalResponses == 0
2175
        }
2176
6460x
        if isFresh {
2177
4857x
            freshQuestions = append(freshQuestions, q)
2178
4857x
        } else {
2179
1603x
            reviewQuestions = append(reviewQuestions, q)
2180
1603x
        }
2181
    }
2182

2183
    // Use probabilistic selection based on the freshness ratio
2184
935x
    var selectedQuestions []*QuestionWithStats
2185
935x
    if len(freshQuestions) > 0 && len(reviewQuestions) > 0 {
2186
203x
        // Both categories available - use probabilistic selection
2187
203x
        if rand.Float64() < freshnessRatio {
2188
109x
            selectedQuestions = freshQuestions
2189
109x
        } else {
2190
94x
            selectedQuestions = reviewQuestions
2191
94x
        }
2192
732x
    } else if len(freshQuestions) > 0 {
2193
732x
        // Only fresh questions available
2194
732x
        selectedQuestions = freshQuestions
2195
732x
    } else if len(reviewQuestions) > 0 {
2196
        // Only review questions available
2197
        selectedQuestions = reviewQuestions
2198
    } else {
2199
        // Fallback to all questions if no separation possible
2200
        selectedQuestions = questions
2201
    }
2202

2203
935x
    if len(selectedQuestions) == 0 {
2204
        return nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "no questions available after freshness filtering")
2205
    }
2206

2207
    // Use weighted random selection within the chosen category
2208
935x
    result, err := s.selectQuestionWithWeightedRandomness(selectedQuestions)
2209
935x
    if err != nil {
2210
        // Log debug info about the selection failure
2211
        s.logger.Warn(context.Background(), "selectQuestionWithWeightedRandomness failed", map[string]interface{}{
2212
            "total_questions":        len(questions),
2213
            "fresh_questions":        len(freshQuestions),
2214
            "review_questions":       len(reviewQuestions),
2215
            "selected_category_size": len(selectedQuestions),
2216
            "freshness_ratio":        freshnessRatio,
2217
            "error":                  err.Error(),
2218
        })
2219
    }
2220
935x
    return result, err
2221
}
2222

2223
// GetUserQuestionCount returns the total number of questions available for a user
2224
3x
func (s *QuestionService) GetUserQuestionCount(ctx context.Context, userID int) (result0 int, err error) {
2225
3x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_user_question_count", observability.AttributeUserID(userID))
2226
3x
    defer func() {
2227
3x
        if err != nil {
2228
            span.RecordError(err, trace.WithStackTrace(true))
2229
            span.SetStatus(codes.Error, err.Error())
2230
        }
2231
3x
        span.End()
2232
    }()
2233
3x
    query := `
2234
3x
        SELECT COUNT(DISTINCT q.id)
2235
3x
        FROM questions q
2236
3x
        JOIN user_questions uq ON q.id = uq.question_id
2237
3x
        WHERE uq.user_id = $1 AND q.status = 'active'
2238
3x
    `
2239
3x

2240
3x
    var count int
2241
3x
    err = s.db.QueryRowContext(ctx, query, userID).Scan(&count)
2242
3x
    if err != nil {
2243
        return 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get user question count: %w", err)
2244
    }
2245
3x
    return count, nil
2246
}
2247

2248
// GetUserResponseCount returns the total number of responses for a user
2249
3x
func (s *QuestionService) GetUserResponseCount(ctx context.Context, userID int) (result0 int, err error) {
2250
3x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_user_response_count", observability.AttributeUserID(userID))
2251
3x
    defer func() {
2252
3x
        if err != nil {
2253
            span.RecordError(err, trace.WithStackTrace(true))
2254
            span.SetStatus(codes.Error, err.Error())
2255
        }
2256
3x
        span.End()
2257
    }()
2258
3x
    query := `SELECT COUNT(*) FROM user_responses WHERE user_id = $1`
2259
3x

2260
3x
    var count int
2261
3x
    err = s.db.QueryRowContext(ctx, query, userID).Scan(&count)
2262
3x
    if err != nil {
2263
        return 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get user response count: %w", err)
2264
    }
2265
3x
    return count, nil
2266
}
2267

2268
// GetUsersForQuestion returns the users assigned to a question, up to 5 users, and the total count
2269
func (s *QuestionService) GetUsersForQuestion(ctx context.Context, questionID int) (result0 []*models.User, result1 int, err error) {
2270
    ctx, span := observability.TraceQuestionFunction(ctx, "get_users_for_question", observability.AttributeQuestionID(questionID))
2271
    defer func() {
2272
        if err != nil {
2273
            span.RecordError(err, trace.WithStackTrace(true))
2274
            span.SetStatus(codes.Error, err.Error())
2275
        }
2276
        span.End()
2277
    }()
2278

2279
    // First get the total count
2280
    countQuery := `SELECT COUNT(*) FROM user_questions WHERE question_id = $1`
2281
    var totalCount int
2282
    err = s.db.QueryRowContext(ctx, countQuery, questionID).Scan(&totalCount)
2283
    if err != nil {
2284
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get user count for question: %w", err)
2285
    }
2286

2287
    // Then get up to 5 users
2288
    usersQuery := `
2289
        SELECT u.id, u.username, u.email, u.timezone, u.password_hash, u.last_active,
2290
               u.preferred_language, u.current_level, u.ai_provider, u.ai_model,
2291
               u.ai_enabled, u.ai_api_key, u.created_at, u.updated_at
2292
        FROM users u
2293
        JOIN user_questions uq ON u.id = uq.user_id
2294
        WHERE uq.question_id = $1
2295
        ORDER BY u.username
2296
        LIMIT 5
2297
    `
2298

2299
    rows, err := s.db.QueryContext(ctx, usersQuery, questionID)
2300
    if err != nil {
2301
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get users for question: %w", err)
2302
    }
2303
    defer func() {
2304
        if err := rows.Close(); err != nil {
2305
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2306
        }
2307
    }()
2308

2309
    var users []*models.User
2310
    for rows.Next() {
2311
        user := &models.User{}
2312
        err = rows.Scan(
2313
            &user.ID,
2314
            &user.Username,
2315
            &user.Email,
2316
            &user.Timezone,
2317
            &user.PasswordHash,
2318
            &user.LastActive,
2319
            &user.PreferredLanguage,
2320
            &user.CurrentLevel,
2321
            &user.AIProvider,
2322
            &user.AIModel,
2323
            &user.AIEnabled,
2324
            &user.AIAPIKey,
2325
            &user.CreatedAt,
2326
            &user.UpdatedAt,
2327
        )
2328
        if err != nil {
2329
            return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to scan user: %w", err)
2330
        }
2331
        users = append(users, user)
2332
    }
2333

2334
    if err = rows.Err(); err != nil {
2335
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "error iterating users: %w", err)
2336
    }
2337

2338
    // Ensure we always return an empty slice instead of nil
2339
    if users == nil {
2340
        users = make([]*models.User, 0)
2341
    }
2342

2343
    return users, totalCount, nil
2344
}
2345

2346
// Helper: scan a *sql.Row into a QuestionWithStats (for single-row queries)
2347
38x
func (s *QuestionService) scanQuestionWithPriorityAndStatsFromRow(row *sql.Row) (result0 *QuestionWithStats, err error) {
2348
38x
    questionWithStats := &QuestionWithStats{
2349
38x
        Question: &models.Question{},
2350
38x
    }
2351
38x
    var contentJSON string
2352
38x
    var priorityScore float64
2353
38x
    var timesAnswered int
2354
38x
    var lastAnsweredAt sql.NullTime
2355
38x

2356
38x
    err = row.Scan(
2357
38x
        &questionWithStats.ID,
2358
38x
        &questionWithStats.Type,
2359
38x
        &questionWithStats.Language,
2360
38x
        &questionWithStats.Level,
2361
38x
        &questionWithStats.DifficultyScore,
2362
38x
        &contentJSON,
2363
38x
        &questionWithStats.CorrectAnswer,
2364
38x
        &questionWithStats.Explanation,
2365
38x
        &questionWithStats.CreatedAt,
2366
38x
        &questionWithStats.Status,
2367
38x
        &questionWithStats.TopicCategory,
2368
38x
        &questionWithStats.GrammarFocus,
2369
38x
        &questionWithStats.VocabularyDomain,
2370
38x
        &questionWithStats.Scenario,
2371
38x
        &questionWithStats.StyleModifier,
2372
38x
        &questionWithStats.DifficultyModifier,
2373
38x
        &questionWithStats.TimeContext,
2374
38x
        &priorityScore,
2375
38x
        &timesAnswered,
2376
38x
        &lastAnsweredAt,
2377
38x
        &questionWithStats.CorrectCount,
2378
38x
        &questionWithStats.IncorrectCount,
2379
38x
        &questionWithStats.TotalResponses,
2380
38x
    )
2381
38x
    if err != nil {
2382
31x
        return nil, err
2383
31x
    }
2384

2385
7x
    if err := questionWithStats.UnmarshalContentFromJSON(contentJSON); err != nil {
2386
        return nil, err
2387
    }
2388

2389
7x
    return questionWithStats, nil
2390
}
2391

2392
// GetRandomGlobalQuestionForUser finds a random question from the global pool for the given language, level, and type that is not already assigned to the user, assigns it, and returns it.
2393
38x
func (s *QuestionService) GetRandomGlobalQuestionForUser(ctx context.Context, userID int, language, level string, qType models.QuestionType) (result0 *QuestionWithStats, err error) {
2394
38x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_random_global_question_for_user", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(qType))
2395
38x
    defer func() {
2396
38x
        if err != nil {
2397
            span.RecordError(err, trace.WithStackTrace(true))
2398
            span.SetStatus(codes.Error, err.Error())
2399
        }
2400
38x
        span.End()
2401
    }()
2402

2403
38x
    query := `
2404
38x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
2405
38x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
2406
38x
               100.0 as priority_score, 0 as times_answered, NULL as last_answered_at, 0 as correct_count, 0 as incorrect_count, 0 as total_responses
2407
38x
        FROM questions q
2408
38x
        WHERE q.language = $1
2409
38x
          AND q.level = $2
2410
38x
        AND q.type = $3
2411
38x
          AND q.status = 'active'
2412
38x
          AND q.id NOT IN (
2413
38x
            SELECT uq.question_id
2414
38x
            FROM user_questions uq
2415
38x
            WHERE uq.user_id = $4
2416
38x
          )
2417
38x
          -- Exclude questions the user marked as known with confidence 5 within last 60 days
2418
38x
          AND NOT EXISTS (
2419
38x
            SELECT 1 FROM user_question_metadata uqm2
2420
38x
            WHERE uqm2.user_id = $4
2421
38x
              AND uqm2.question_id = q.id
2422
38x
              AND uqm2.marked_as_known = TRUE
2423
38x
              AND uqm2.confidence_level = 5
2424
38x
              AND uqm2.marked_as_known_at >= NOW() - INTERVAL '60 days'
2425
38x
          )
2426
38x
        ORDER BY RANDOM()
2427
38x
        LIMIT 1
2428
38x
    `
2429
38x

2430
38x
    row := s.db.QueryRowContext(ctx, query, language, level, qType, userID)
2431
38x
    questionWithStats, err := s.scanQuestionWithPriorityAndStatsFromRow(row)
2432
38x
    if err != nil {
2433
31x
        if errors.Is(err, sql.ErrNoRows) {
2434
31x
            return nil, nil // No global questions available
2435
31x
        }
2436
        return nil, err
2437
    }
2438

2439
    // Assign the question to the user
2440
7x
    err = s.AssignQuestionToUser(ctx, questionWithStats.ID, userID)
2441
7x
    if err != nil {
2442
        s.logger.Warn(ctx, "Failed to assign global question to user", map[string]interface{}{"question_id": questionWithStats.ID, "user_id": userID, "error": err.Error()})
2443
        // Still return the question, but log the error
2444
    }
2445

2446
7x
    return questionWithStats, nil
2447
}
2448

2449
// GetAllQuestionsPaginated returns all questions with pagination and filtering
2450
1x
func (s *QuestionService) GetAllQuestionsPaginated(ctx context.Context, page, pageSize int, search, typeFilter, statusFilter, languageFilter, levelFilter string, userID *int) (result0 []*QuestionWithStats, result1 int, err error) {
2451
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_all_questions_paginated")
2452
1x
    defer func() {
2453
1x
        if err != nil {
2454
            span.RecordError(err, trace.WithStackTrace(true))
2455
            span.SetStatus(codes.Error, err.Error())
2456
        }
2457
1x
        span.End()
2458
    }()
2459

2460
    // Build the base query
2461
1x
    baseQuery := `
2462
1x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
2463
1x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
2464
1x
               COALESCE(ur_stats.correct_count, 0) as correct_count,
2465
1x
               COALESCE(ur_stats.incorrect_count, 0) as incorrect_count,
2466
1x
               COALESCE(ur_stats.total_responses, 0) as total_responses,
2467
1x
               COALESCE(uq_stats.user_count, 0) as user_count
2468
1x
        FROM questions q
2469
1x
        LEFT JOIN (
2470
1x
            SELECT
2471
1x
                question_id,
2472
1x
                COUNT(CASE WHEN is_correct = true THEN 1 END) as correct_count,
2473
1x
                COUNT(CASE WHEN is_correct = false THEN 1 END) as incorrect_count,
2474
1x
                COUNT(*) as total_responses
2475
1x
            FROM user_responses
2476
1x
            GROUP BY question_id
2477
1x
        ) ur_stats ON q.id = ur_stats.question_id
2478
1x
        LEFT JOIN (
2479
1x
            SELECT
2480
1x
                question_id,
2481
1x
                COUNT(*) as user_count
2482
1x
            FROM user_questions
2483
1x
            GROUP BY question_id
2484
1x
        ) uq_stats ON q.id = uq_stats.question_id
2485
1x
        WHERE 1=1
2486
1x
    `
2487
1x

2488
1x
    // Build the count query
2489
1x
    countQuery := `
2490
1x
        SELECT COUNT(*)
2491
1x
        FROM questions q
2492
1x
        WHERE 1=1
2493
1x
    `
2494
1x

2495
1x
    var args []interface{}
2496
1x
    argIndex := 1
2497
1x

2498
1x
    // Add filters
2499
1x
    if search != "" {
2500
        searchCondition := ` AND (q.content::text ILIKE $` + strconv.Itoa(argIndex) + ` OR q.explanation ILIKE $` + strconv.Itoa(argIndex) + `)`
2501
        baseQuery += searchCondition
2502
        countQuery += searchCondition
2503
        args = append(args, "%"+search+"%")
2504
        argIndex++
2505
    }
2506

2507
1x
    if typeFilter != "" {
2508
        typeCondition := ` AND q.type = $` + strconv.Itoa(argIndex)
2509
        baseQuery += typeCondition
2510
        countQuery += typeCondition
2511
        args = append(args, typeFilter)
2512
        argIndex++
2513
    }
2514

2515
1x
    if statusFilter != "" {
2516
        statusCondition := ` AND q.status = $` + strconv.Itoa(argIndex)
2517
        baseQuery += statusCondition
2518
        countQuery += statusCondition
2519
        args = append(args, statusFilter)
2520
        argIndex++
2521
    }
2522

2523
1x
    if languageFilter != "" {
2524
1x
        languageCondition := ` AND q.language = $` + strconv.Itoa(argIndex)
2525
1x
        baseQuery += languageCondition
2526
1x
        countQuery += languageCondition
2527
1x
        args = append(args, languageFilter)
2528
1x
        argIndex++
2529
1x
    }
2530

2531
1x
    if levelFilter != "" {
2532
1x
        levelCondition := ` AND q.level = $` + strconv.Itoa(argIndex)
2533
1x
        baseQuery += levelCondition
2534
1x
        countQuery += levelCondition
2535
1x
        args = append(args, levelFilter)
2536
1x
        argIndex++
2537
1x
    }
2538

2539
1x
    if userID != nil {
2540
        userCondition := ` AND q.id IN (SELECT question_id FROM user_questions WHERE user_id = $` + strconv.Itoa(argIndex) + `)`
2541
        baseQuery += userCondition
2542
        countQuery += userCondition
2543
        args = append(args, *userID)
2544
        argIndex++
2545
    }
2546

2547
    // Get total count
2548
1x
    var total int
2549
1x
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total)
2550
1x
    if err != nil {
2551
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get total count: %w", err)
2552
    }
2553

2554
    // Add pagination
2555
1x
    offset := (page - 1) * pageSize
2556
1x
    baseQuery += ` ORDER BY q.created_at DESC LIMIT $` + strconv.Itoa(argIndex) + ` OFFSET $` + strconv.Itoa(argIndex+1)
2557
1x
    args = append(args, pageSize, offset)
2558
1x

2559
1x
    // Execute the main query
2560
1x
    rows, err := s.db.QueryContext(ctx, baseQuery, args...)
2561
1x
    if err != nil {
2562
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get questions: %w", err)
2563
    }
2564
1x
    defer func() {
2565
1x
        if closeErr := rows.Close(); closeErr != nil {
2566
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
2567
        }
2568
    }()
2569

2570
1x
    var questions []*QuestionWithStats
2571
1x
    for rows.Next() {
2572
        question, err := s.scanQuestionWithStatsAndAllFieldsFromRows(rows)
2573
        if err != nil {
2574
            return nil, 0, err
2575
        }
2576
        questions = append(questions, question)
2577
    }
2578

2579
1x
    return questions, total, nil
2580
}
2581

2582
// GetReportedQuestionsPaginated returns reported questions with pagination and filtering
2583
13x
func (s *QuestionService) GetReportedQuestionsPaginated(ctx context.Context, page, pageSize int, search, typeFilter, languageFilter, levelFilter string) (result0 []*QuestionWithStats, result1 int, err error) {
2584
13x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_reported_questions_paginated")
2585
13x
    defer func() {
2586
13x
        if err != nil {
2587
            span.RecordError(err, trace.WithStackTrace(true))
2588
            span.SetStatus(codes.Error, err.Error())
2589
        }
2590
13x
        span.End()
2591
    }()
2592

2593
    // Validate pagination parameters
2594
13x
    if page < 1 {
2595
1x
        page = 1
2596
1x
    }
2597
13x
    if pageSize < 1 {
2598
1x
        pageSize = 10
2599
1x
    }
2600

2601
    // Build WHERE clause with filters using parameterized queries
2602
13x
    whereConditions := []string{"q.status = 'reported'"}
2603
13x
    args := []interface{}{}
2604
13x
    argCount := 0
2605
13x

2606
13x
    // Add search filter
2607
13x
    if search != "" {
2608
2x
        argCount++
2609
2x
        whereConditions = append(whereConditions, fmt.Sprintf("(q.content::text ILIKE $%d OR q.explanation ILIKE $%d)", argCount, argCount))
2610
2x
        args = append(args, "%"+search+"%")
2611
2x
    }
2612

2613
    // Add type filter
2614
13x
    if typeFilter != "" {
2615
2x
        argCount++
2616
2x
        whereConditions = append(whereConditions, fmt.Sprintf("q.type = $%d", argCount))
2617
2x
        args = append(args, typeFilter)
2618
2x
    }
2619

2620
    // Add language filter
2621
13x
    if languageFilter != "" {
2622
2x
        argCount++
2623
2x
        whereConditions = append(whereConditions, fmt.Sprintf("q.language = $%d", argCount))
2624
2x
        args = append(args, languageFilter)
2625
2x
    }
2626

2627
    // Add level filter
2628
13x
    if levelFilter != "" {
2629
2x
        argCount++
2630
2x
        whereConditions = append(whereConditions, fmt.Sprintf("q.level = $%d", argCount))
2631
2x
        args = append(args, levelFilter)
2632
2x
    }
2633

2634
    // Join all conditions
2635
13x
    whereClause := "WHERE " + strings.Join(whereConditions, " AND ")
2636
13x

2637
13x
    // Build the count query
2638
13x
    countQuery := fmt.Sprintf("SELECT COUNT(DISTINCT q.id) FROM questions q %s", whereClause)
2639
13x
    var total int
2640
13x
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total)
2641
13x
    if err != nil {
2642
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get total count: %w", err)
2643
    }
2644

2645
    // Calculate offset
2646
13x
    offset := (page - 1) * pageSize
2647
13x

2648
13x
    // Build main query with pagination
2649
13x
    query := fmt.Sprintf(`
2650
13x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
2651
13x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
2652
13x
               COALESCE(ur_stats.correct_count, 0) as correct_count,
2653
13x
               COALESCE(ur_stats.incorrect_count, 0) as incorrect_count,
2654
13x
               COALESCE(ur_stats.total_responses, 0) as total_responses,
2655
13x
               STRING_AGG(DISTINCT u.username, ', ') as reporters,
2656
13x
               STRING_AGG(DISTINCT qr.report_reason, ' | ') as report_reasons
2657
13x
        FROM questions q
2658
13x
        LEFT JOIN (
2659
13x
            SELECT
2660
13x
                question_id,
2661
13x
                COUNT(CASE WHEN is_correct = true THEN 1 END) as correct_count,
2662
13x
                COUNT(CASE WHEN is_correct = false THEN 1 END) as incorrect_count,
2663
13x
                COUNT(*) as total_responses
2664
13x
            FROM user_responses
2665
13x
            GROUP BY question_id
2666
13x
        ) ur_stats ON q.id = ur_stats.question_id
2667
13x
        LEFT JOIN question_reports qr ON q.id = qr.question_id
2668
13x
        LEFT JOIN users u ON qr.reported_by_user_id = u.id
2669
13x
        %s
2670
13x
        GROUP BY q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
2671
13x
                 q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
2672
13x
                 ur_stats.correct_count, ur_stats.incorrect_count, ur_stats.total_responses
2673
13x
        ORDER BY q.created_at DESC
2674
13x
        LIMIT $%d OFFSET $%d
2675
13x
    `, whereClause, argCount+1, argCount+2)
2676
13x

2677
13x
    // Add pagination parameters
2678
13x
    args = append(args, pageSize, offset)
2679
13x

2680
13x
    // Execute the main query
2681
13x
    rows, err := s.db.QueryContext(ctx, query, args...)
2682
13x
    if err != nil {
2683
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get reported questions: %w", err)
2684
    }
2685
13x
    defer func() {
2686
13x
        if closeErr := rows.Close(); closeErr != nil {
2687
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
2688
        }
2689
    }()
2690

2691
13x
    var questions []*QuestionWithStats
2692
13x
    for rows.Next() {
2693
11x
        question, err := s.scanQuestionWithStatsAndReportersFromRows(rows)
2694
11x
        if err != nil {
2695
            return nil, 0, err
2696
        }
2697
11x
        questions = append(questions, question)
2698
    }
2699

2700
13x
    return questions, total, nil
2701
}
2702

2703
// GetReportedQuestionsStats returns statistics about reported questions
2704
1x
func (s *QuestionService) GetReportedQuestionsStats(ctx context.Context) (result0 map[string]interface{}, err error) {
2705
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_reported_questions_stats")
2706
1x
    defer func() {
2707
1x
        if err != nil {
2708
            span.RecordError(err, trace.WithStackTrace(true))
2709
            span.SetStatus(codes.Error, err.Error())
2710
        }
2711
1x
        span.End()
2712
    }()
2713

2714
1x
    stats := make(map[string]interface{})
2715
1x

2716
1x
    // Get total reported questions
2717
1x
    var totalReported int
2718
1x
    err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM questions WHERE status = 'reported'`).Scan(&totalReported)
2719
1x
    if err != nil {
2720
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get total reported questions: %w", err)
2721
    }
2722
1x
    stats["total_reported"] = totalReported
2723
1x

2724
1x
    // Get reported questions by type
2725
1x
    rows, err := s.db.QueryContext(ctx, `
2726
1x
        SELECT type, COUNT(*) as count
2727
1x
        FROM questions
2728
1x
        WHERE status = 'reported'
2729
1x
        GROUP BY type
2730
1x
        ORDER BY count DESC
2731
1x
    `)
2732
1x
    if err != nil {
2733
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get reported questions by type: %w", err)
2734
    }
2735
1x
    defer func() {
2736
1x
        if closeErr := rows.Close(); closeErr != nil {
2737
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
2738
        }
2739
    }()
2740

2741
1x
    reportedByType := make(map[string]int)
2742
1x
    for rows.Next() {
2743
2x
        var questionType string
2744
2x
        var count int
2745
2x
        if err := rows.Scan(&questionType, &count); err != nil {
2746
            return nil, err
2747
        }
2748
2x
        reportedByType[questionType] = count
2749
    }
2750
1x
    stats["reported_by_type"] = reportedByType
2751
1x

2752
1x
    // Get reported questions by level
2753
1x
    rows, err = s.db.QueryContext(ctx, `
2754
1x
        SELECT level, COUNT(*) as count
2755
1x
        FROM questions
2756
1x
        WHERE status = 'reported'
2757
1x
        GROUP BY level
2758
1x
        ORDER BY count DESC
2759
1x
    `)
2760
1x
    if err != nil {
2761
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get reported questions by level: %w", err)
2762
    }
2763
1x
    defer func() {
2764
1x
        if closeErr := rows.Close(); closeErr != nil {
2765
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
2766
        }
2767
    }()
2768

2769
1x
    reportedByLevel := make(map[string]int)
2770
1x
    for rows.Next() {
2771
3x
        var level string
2772
3x
        var count int
2773
3x
        if err := rows.Scan(&level, &count); err != nil {
2774
            return nil, err
2775
        }
2776
3x
        reportedByLevel[level] = count
2777
    }
2778
1x
    stats["reported_by_level"] = reportedByLevel
2779
1x

2780
1x
    // Get reported questions by language
2781
1x
    rows, err = s.db.QueryContext(ctx, `
2782
1x
        SELECT language, COUNT(*) as count
2783
1x
        FROM questions
2784
1x
        WHERE status = 'reported'
2785
1x
        GROUP BY language
2786
1x
        ORDER BY count DESC
2787
1x
    `)
2788
1x
    if err != nil {
2789
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get reported questions by language: %w", err)
2790
    }
2791
1x
    defer func() {
2792
1x
        if closeErr := rows.Close(); closeErr != nil {
2793
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
2794
        }
2795
    }()
2796

2797
1x
    reportedByLanguage := make(map[string]int)
2798
1x
    for rows.Next() {
2799
2x
        var language string
2800
2x
        var count int
2801
2x
        if err := rows.Scan(&language, &count); err != nil {
2802
            return nil, err
2803
        }
2804
2x
        reportedByLanguage[language] = count
2805
    }
2806
1x
    stats["reported_by_language"] = reportedByLanguage
2807
1x

2808
1x
    return stats, nil
2809
}
2810

2811
// AssignUsersToQuestion assigns multiple users to a question
2812
func (s *QuestionService) AssignUsersToQuestion(ctx context.Context, questionID int, userIDs []int) (err error) {
2813
    ctx, span := observability.TraceQuestionFunction(ctx, "assign_users_to_question", observability.AttributeQuestionID(questionID))
2814
    defer func() {
2815
        if err != nil {
2816
            span.RecordError(err, trace.WithStackTrace(true))
2817
            span.SetStatus(codes.Error, err.Error())
2818
        }
2819
        span.End()
2820
    }()
2821

2822
    // Start a transaction
2823
    tx, err := s.db.BeginTx(ctx, nil)
2824
    if err != nil {
2825
        return contextutils.WrapError(err, "failed to begin transaction")
2826
    }
2827
    defer func() {
2828
        if err != nil {
2829
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
2830
                s.logger.Warn(ctx, "Failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
2831
            }
2832
        }
2833
    }()
2834

2835
    // Prepare the insert statement
2836
    stmt, err := tx.PrepareContext(ctx, `
2837
        INSERT INTO user_questions (user_id, question_id, created_at)
2838
        VALUES ($1, $2, NOW())
2839
        ON CONFLICT (user_id, question_id) DO NOTHING
2840
    `)
2841
    if err != nil {
2842
        return contextutils.WrapError(err, "failed to prepare insert statement")
2843
    }
2844
    defer func() {
2845
        if closeErr := stmt.Close(); closeErr != nil {
2846
            s.logger.Warn(ctx, "Warning: failed to close statement", map[string]interface{}{"error": closeErr.Error()})
2847
        }
2848
    }()
2849

2850
    // Insert each user-question mapping
2851
    for _, userID := range userIDs {
2852
        _, err = stmt.ExecContext(ctx, userID, questionID)
2853
        if err != nil {
2854
            return contextutils.WrapErrorf(err, "failed to assign user %d to question %d", userID, questionID)
2855
        }
2856
    }
2857

2858
    // Commit the transaction
2859
    err = tx.Commit()
2860
    if err != nil {
2861
        return contextutils.WrapError(err, "failed to commit transaction")
2862
    }
2863

2864
    return nil
2865
}
2866

2867
// UnassignUsersFromQuestion removes multiple users from a question
2868
func (s *QuestionService) UnassignUsersFromQuestion(ctx context.Context, questionID int, userIDs []int) (err error) {
2869
    ctx, span := observability.TraceQuestionFunction(ctx, "unassign_users_from_question", observability.AttributeQuestionID(questionID))
2870
    defer func() {
2871
        if err != nil {
2872
            span.RecordError(err, trace.WithStackTrace(true))
2873
            span.SetStatus(codes.Error, err.Error())
2874
        }
2875
        span.End()
2876
    }()
2877

2878
    // Start a transaction
2879
    tx, err := s.db.BeginTx(ctx, nil)
2880
    if err != nil {
2881
        return contextutils.WrapError(err, "failed to begin transaction")
2882
    }
2883
    defer func() {
2884
        if err != nil {
2885
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
2886
                s.logger.Warn(ctx, "Failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
2887
            }
2888
        }
2889
    }()
2890

2891
    // Prepare the delete statement
2892
    stmt, err := tx.PrepareContext(ctx, `
2893
        DELETE FROM user_questions
2894
        WHERE user_id = $1 AND question_id = $2
2895
    `)
2896
    if err != nil {
2897
        return contextutils.WrapError(err, "failed to prepare delete statement")
2898
    }
2899
    defer func() {
2900
        if closeErr := stmt.Close(); closeErr != nil {
2901
            s.logger.Warn(ctx, "Warning: failed to close statement", map[string]interface{}{"error": closeErr.Error()})
2902
        }
2903
    }()
2904

2905
    // Delete each user-question mapping
2906
    for _, userID := range userIDs {
2907
        _, err = stmt.ExecContext(ctx, userID, questionID)
2908
        if err != nil {
2909
            return contextutils.WrapErrorf(err, "failed to unassign user %d from question %d", userID, questionID)
2910
        }
2911
    }
2912

2913
    // Commit the transaction
2914
    err = tx.Commit()
2915
    if err != nil {
2916
        return contextutils.WrapError(err, "failed to commit transaction")
2917
    }
2918

2919
    return nil
2920
}
2921

2922
// DB returns the underlying *sql.DB instance
2923
func (s *QuestionService) DB() *sql.DB {
2924
    return s.db
2925
}
2926


			
quizapp internal services worker_service.go
64.3%
Statements
232/361
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "fmt"
7
    "strings"
8

9
    "quizapp/internal/api"
10
    "quizapp/internal/config"
11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    "quizapp/internal/serviceinterfaces"
14
    contextutils "quizapp/internal/utils"
15

16
    "github.com/lib/pq"
17
    "go.opentelemetry.io/otel/attribute"
18
)
19

20
// SnippetsServiceInterface defines the interface for snippets services
21
type SnippetsServiceInterface = serviceinterfaces.SnippetsService
22

23
// SnippetsService handles snippets related business logic
24
type SnippetsService struct {
25
    db     *sql.DB
26
    cfg    *config.Config
27
    logger *observability.Logger
28
}
29

30
// NewSnippetsService creates a new SnippetsService instance
31
10x
func NewSnippetsService(db *sql.DB, cfg *config.Config, logger *observability.Logger) *SnippetsService {
32
10x
    return &SnippetsService{
33
10x
        db:     db,
34
10x
        cfg:    cfg,
35
10x
        logger: logger,
36
10x
    }
37
10x
}
38

39
// getDefaultDifficultyLevel returns a sensible default difficulty level when no question context is available
40
14x
func (s *SnippetsService) getDefaultDifficultyLevel() string {
41
14x
    // Default to "Unknown" when no question context is available
42
14x
    // Users can always update this through the UI if needed
43
14x
    return "Unknown"
44
14x
}
45

46
// getQuestionLevel retrieves the difficulty level of a specific question
47
2x
func (s *SnippetsService) getQuestionLevel(ctx context.Context, questionID int64) (result string, err error) {
48
2x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_question_level",
49
2x
        observability.AttributeQuestionID(int(questionID)),
50
2x
    )
51
2x
    defer observability.FinishSpan(span, &err)
52
2x

53
2x
    // Check if database connection is valid
54
2x
    if s.db == nil {
55
        return "", contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
56
    }
57

58
2x
    query := `SELECT level FROM questions WHERE id = $1`
59
2x

60
2x
    err = s.db.QueryRowContext(ctx, query, questionID).Scan(&result)
61
2x
    if err != nil {
62
        if err == sql.ErrNoRows {
63
            return "", contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with id %d not found", questionID)
64
        }
65
        return "", contextutils.WrapErrorf(err, "failed to get question level for question %d", questionID)
66
    }
67
2x
    return result, nil
68
}
69

70
// CreateSnippet creates a new vocabulary snippet
71
17x
func (s *SnippetsService) CreateSnippet(ctx context.Context, userID int64, req api.CreateSnippetRequest) (result *models.Snippet, err error) {
72
17x
    ctx, span := observability.TraceFunction(ctx, "snippets", "create_snippet")
73
17x
    defer observability.FinishSpan(span, &err)
74
17x

75
17x
    // Check if database connection is valid
76
17x
    if s.db == nil {
77
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
78
    }
79

80
17x
    span.SetAttributes(observability.AttributeUserID(int(userID)))
81
17x

82
17x
    // Check if snippet already exists for this user and text combination
83
17x
    exists, err := s.snippetExists(ctx, userID, req.OriginalText, req.SourceLanguage, req.TargetLanguage)
84
17x
    if err != nil {
85
        return nil, contextutils.WrapErrorf(err, "failed to check snippet existence")
86
    }
87
17x
    if exists {
88
1x
        return nil, contextutils.WrapError(contextutils.ErrRecordExists, "snippet already exists for this user and text combination")
89
1x
    }
90

91
    // Determine difficulty level - use question's level if question_id is provided, or section's level if section_id is provided
92
16x
    var difficultyLevel string
93
16x
    var levelSource string
94
16x

95
16x
    if req.QuestionId != nil {
96
2x
        // Get the question's difficulty level
97
2x
        questionLevel, err := s.getQuestionLevel(ctx, *req.QuestionId)
98
2x
        if err != nil {
99
            // If we can't get the question level, use default
100
            s.logger.Warn(ctx, "Failed to get question level, using default",
101
                map[string]any{"question_id": *req.QuestionId, "error": err.Error()})
102
            difficultyLevel = s.getDefaultDifficultyLevel()
103
            levelSource = "default_fallback"
104
        } else {
105
2x
            difficultyLevel = questionLevel
106
2x
            levelSource = "question"
107
2x
        }
108
14x
    } else if req.SectionId != nil {
109
        // Get the story section's language level
110
        sectionLevel, err := s.getSectionLevel(ctx, *req.SectionId)
111
        if err != nil {
112
            // If we can't get the section level, use default
113
            s.logger.Warn(ctx, "Failed to get section level, using default",
114
                map[string]any{"section_id": *req.SectionId, "error": err.Error()})
115
            difficultyLevel = s.getDefaultDifficultyLevel()
116
            levelSource = "default_fallback"
117
        } else {
118
            difficultyLevel = sectionLevel
119
            levelSource = "section"
120
        }
121
14x
    } else {
122
14x
        // No question or section context, use default
123
14x
        difficultyLevel = s.getDefaultDifficultyLevel()
124
14x
        levelSource = "default"
125
14x
    }
126
16x
    span.SetAttributes(observability.AttributeLevel(difficultyLevel))
127
16x

128
16x
    // Insert new snippet
129
16x
    query := `
130
16x
        INSERT INTO snippets (user_id, original_text, translated_text, source_language, target_language, question_id, section_id, story_id, context, difficulty_level)
131
16x
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
132
16x
        RETURNING id, created_at, updated_at`
133
16x

134
16x
    result = &models.Snippet{}
135
16x
    err = s.db.QueryRowContext(ctx, query,
136
16x
        userID,
137
16x
        req.OriginalText,
138
16x
        req.TranslatedText,
139
16x
        req.SourceLanguage,
140
16x
        req.TargetLanguage,
141
16x
        req.QuestionId,
142
16x
        req.SectionId,
143
16x
        req.StoryId,
144
16x
        req.Context,
145
16x
        difficultyLevel,
146
16x
    ).Scan(&result.ID, &result.CreatedAt, &result.UpdatedAt)
147
16x
    if err != nil {
148
        return nil, contextutils.WrapErrorf(err, "failed to create snippet")
149
    }
150

151
    // Set the remaining fields
152
16x
    result.UserID = userID
153
16x
    result.OriginalText = req.OriginalText
154
16x
    result.TranslatedText = req.TranslatedText
155
16x
    result.SourceLanguage = req.SourceLanguage
156
16x
    result.TargetLanguage = req.TargetLanguage
157
16x
    result.QuestionID = req.QuestionId
158
16x
    result.SectionID = req.SectionId
159
16x
    result.StoryID = req.StoryId
160
16x
    result.Context = req.Context
161
16x
    result.DifficultyLevel = &difficultyLevel
162
16x

163
16x
    s.logger.Info(ctx, "Created new snippet",
164
16x
        map[string]any{
165
16x
            "snippet_id":       result.ID,
166
16x
            "user_id":          userID,
167
16x
            "original_text":    req.OriginalText,
168
16x
            "source_language":  req.SourceLanguage,
169
16x
            "difficulty_level": difficultyLevel,
170
16x
            "level_source":     levelSource,
171
16x
            "question_id":      req.QuestionId,
172
16x
        })
173
16x

174
16x
    return result, nil
175
}
176

177
// getSectionLevel retrieves the language level of a specific story section
178
func (s *SnippetsService) getSectionLevel(ctx context.Context, sectionID int64) (result string, err error) {
179
    ctx, span := observability.TraceFunction(ctx, "snippets", "get_section_level")
180
    defer observability.FinishSpan(span, &err)
181

182
    // Check if database connection is valid
183
    if s.db == nil {
184
        return "", contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
185
    }
186

187
    query := `SELECT language_level FROM story_sections WHERE id = $1`
188

189
    err = s.db.QueryRowContext(ctx, query, sectionID).Scan(&result)
190
    if err != nil {
191
        if err == sql.ErrNoRows {
192
            return "", contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "story section with id %d not found", sectionID)
193
        }
194
        return "", contextutils.WrapErrorf(err, "failed to get section level for section %d", sectionID)
195
    }
196
    return result, nil
197
}
198

199
// GetSnippets retrieves snippets for a user with optional filtering
200
10x
func (s *SnippetsService) GetSnippets(ctx context.Context, userID int64, params api.GetV1SnippetsParams) (result *api.SnippetList, err error) {
201
10x
    ctx, span := observability.TraceFunction(ctx, "snippets", "get_snippets")
202
10x
    defer observability.FinishSpan(span, &err)
203
10x

204
10x
    // Check if database connection is valid
205
10x
    if s.db == nil {
206
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
207
    }
208

209
10x
    span.SetAttributes(observability.AttributeUserID(int(userID)))
210
10x

211
10x
    query := `
212
10x
        SELECT id, user_id, original_text, translated_text, source_language, target_language,
213
10x
               question_id, section_id, story_id, context, difficulty_level, created_at, updated_at
214
10x
        FROM snippets
215
10x
        WHERE user_id = $1`
216
10x

217
10x
    args := []any{userID}
218
10x
    argCount := 1
219
10x

220
10x
    // Add search filter if provided
221
10x
    if params.Q != nil && *params.Q != "" {
222
2x
        argCount++
223
2x
        query += fmt.Sprintf(" AND (original_text ILIKE $%d OR translated_text ILIKE $%d)", argCount, argCount)
224
2x
        searchTerm := "%" + *params.Q + "%"
225
2x
        args = append(args, searchTerm)
226
2x
    }
227

228
    // Add source language filter if provided
229
10x
    if params.SourceLang != nil && *params.SourceLang != "" {
230
1x
        argCount++
231
1x
        query += fmt.Sprintf(" AND source_language = $%d", argCount)
232
1x
        args = append(args, *params.SourceLang)
233
1x
    }
234

235
    // Add target language filter if provided
236
10x
    if params.TargetLang != nil && *params.TargetLang != "" {
237
        argCount++
238
        query += fmt.Sprintf(" AND target_language = $%d", argCount)
239
        args = append(args, *params.TargetLang)
240
    }
241

242
    // Add story_id filter if provided
243
10x
    if params.StoryId != nil && *params.StoryId > 0 {
244
1x
        argCount++
245
1x
        query += fmt.Sprintf(" AND story_id = $%d", argCount)
246
1x
        args = append(args, *params.StoryId)
247
1x
    }
248

249
    // Add difficulty level filter if provided
250
10x
    if params.Level != nil && *params.Level != "" {
251
4x
        argCount++
252
4x
        query += fmt.Sprintf(" AND difficulty_level = $%d", argCount)
253
4x
        args = append(args, string(*params.Level))
254
4x
    }
255

256
    // Add ordering and pagination
257
10x
    query += " ORDER BY created_at DESC"
258
10x

259
10x
    if params.Limit != nil && *params.Limit > 0 {
260
        argCount++
261
        query += fmt.Sprintf(" LIMIT $%d", argCount)
262
        limit := *params.Limit
263
        if limit > 100 { // Max limit
264
            limit = 100
265
        }
266
        args = append(args, limit)
267
    }
268

269
10x
    if params.Offset != nil && *params.Offset > 0 {
270
        argCount++
271
        query += fmt.Sprintf(" OFFSET $%d", argCount)
272
        args = append(args, *params.Offset)
273
    }
274

275
    // Execute query
276
10x
    rows, err := s.db.QueryContext(ctx, query, args...)
277
10x
    if err != nil {
278
        return nil, contextutils.WrapErrorf(err, "failed to query snippets")
279
    }
280
10x
    defer func() {
281
10x
        if closeErr := rows.Close(); closeErr != nil {
282
            s.logger.Warn(ctx, "Failed to close rows", map[string]any{"error": closeErr.Error()})
283
        }
284
    }()
285

286
10x
    snippets := []api.Snippet{}
287
10x
    for rows.Next() {
288
11x
        var snippet models.Snippet
289
11x
        err := rows.Scan(
290
11x
            &snippet.ID,
291
11x
            &snippet.UserID,
292
11x
            &snippet.OriginalText,
293
11x
            &snippet.TranslatedText,
294
11x
            &snippet.SourceLanguage,
295
11x
            &snippet.TargetLanguage,
296
11x
            &snippet.QuestionID,
297
11x
            &snippet.SectionID,
298
11x
            &snippet.StoryID,
299
11x
            &snippet.Context,
300
11x
            &snippet.DifficultyLevel,
301
11x
            &snippet.CreatedAt,
302
11x
            &snippet.UpdatedAt,
303
11x
        )
304
11x
        if err != nil {
305
            return nil, contextutils.WrapErrorf(err, "failed to scan snippet")
306
        }
307

308
11x
        snippets = append(snippets, api.Snippet{
309
11x
            Id:              &snippet.ID,
310
11x
            UserId:          &snippet.UserID,
311
11x
            OriginalText:    &snippet.OriginalText,
312
11x
            TranslatedText:  &snippet.TranslatedText,
313
11x
            SourceLanguage:  &snippet.SourceLanguage,
314
11x
            TargetLanguage:  &snippet.TargetLanguage,
315
11x
            QuestionId:      snippet.QuestionID,
316
11x
            SectionId:       snippet.SectionID,
317
11x
            StoryId:         snippet.StoryID,
318
11x
            Context:         snippet.Context,
319
11x
            DifficultyLevel: snippet.DifficultyLevel,
320
11x
            CreatedAt:       &snippet.CreatedAt,
321
11x
            UpdatedAt:       &snippet.UpdatedAt,
322
11x
        })
323
    }
324

325
    // Get total count for pagination info
326
10x
    totalQuery := "SELECT COUNT(*) FROM snippets WHERE user_id = $1"
327
10x
    totalArgs := []interface{}{userID}
328
10x

329
10x
    // Apply the same filters for total count
330
10x
    if params.Q != nil && *params.Q != "" {
331
2x
        totalQuery += " AND (original_text ILIKE $2 OR translated_text ILIKE $2)"
332
2x
        totalArgs = append(totalArgs, "%"+*params.Q+"%")
333
2x
    }
334
10x
    if params.SourceLang != nil && *params.SourceLang != "" {
335
1x
        totalQuery += fmt.Sprintf(" AND source_language = $%d", len(totalArgs)+1)
336
1x
        totalArgs = append(totalArgs, *params.SourceLang)
337
1x
    }
338
10x
    if params.TargetLang != nil && *params.TargetLang != "" {
339
        totalQuery += fmt.Sprintf(" AND target_language = $%d", len(totalArgs)+1)
340
        totalArgs = append(totalArgs, *params.TargetLang)
341
    }
342
10x
    if params.StoryId != nil && *params.StoryId > 0 {
343
1x
        totalQuery += fmt.Sprintf(" AND story_id = $%d", len(totalArgs)+1)
344
1x
        totalArgs = append(totalArgs, *params.StoryId)
345
1x
    }
346
10x
    if params.Level != nil && *params.Level != "" {
347
4x
        totalQuery += fmt.Sprintf(" AND difficulty_level = $%d", len(totalArgs)+1)
348
4x
        totalArgs = append(totalArgs, string(*params.Level))
349
4x
    }
350

351
10x
    var total int
352
10x
    err = s.db.QueryRowContext(ctx, totalQuery, totalArgs...).Scan(&total)
353
10x
    if err != nil {
354
        return nil, contextutils.WrapErrorf(err, "failed to get total count")
355
    }
356

357
    // Build response
358
10x
    limit := 50 // default
359
10x
    offset := 0 // default
360
10x
    if params.Limit != nil {
361
        limit = *params.Limit
362
    }
363
10x
    if params.Offset != nil {
364
        offset = *params.Offset
365
    }
366

367
10x
    result = &api.SnippetList{
368
10x
        Snippets: &snippets,
369
10x
        Total:    &total,
370
10x
        Limit:    &limit,
371
10x
        Offset:   &offset,
372
10x
        Query:    params.Q,
373
10x
    }
374
10x

375
10x
    return result, nil
376
}
377

378
// GetSnippetsByQuestion retrieves snippets for a user filtered by question ID
379
// This method is optimized for performance to support async loading in the UI
380
3x
func (s *SnippetsService) GetSnippetsByQuestion(ctx context.Context, userID, questionID int64) (result []api.Snippet, err error) {
381
3x
    ctx, span := observability.TraceFunction(ctx, "snippets", "get_snippets_by_question")
382
3x
    defer observability.FinishSpan(span, &err)
383
3x

384
3x
    // Check if database connection is valid
385
3x
    if s.db == nil {
386
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
387
    }
388

389
3x
    span.SetAttributes(
390
3x
        observability.AttributeUserID(int(userID)),
391
3x
        observability.AttributeQuestionID(int(questionID)),
392
3x
    )
393
3x

394
3x
    // Query snippets for this user and question
395
3x
    // Uses the existing idx_snippets_question_id index for performance
396
3x
    query := `
397
3x
        SELECT id, user_id, original_text, translated_text, source_language, target_language,
398
3x
               question_id, context, difficulty_level, created_at, updated_at
399
3x
        FROM snippets
400
3x
        WHERE user_id = $1 AND question_id = $2
401
3x
        ORDER BY created_at DESC`
402
3x

403
3x
    rows, err := s.db.QueryContext(ctx, query, userID, questionID)
404
3x
    if err != nil {
405
        return nil, contextutils.WrapErrorf(err, "failed to get snippets by question")
406
    }
407
3x
    defer func() {
408
3x
        if closeErr := rows.Close(); closeErr != nil {
409
            s.logger.Warn(ctx, "Failed to close rows", map[string]any{"error": closeErr.Error()})
410
        }
411
    }()
412

413
3x
    snippets := []api.Snippet{}
414
3x
    for rows.Next() {
415
2x
        var snippet models.Snippet
416
2x
        err := rows.Scan(
417
2x
            &snippet.ID,
418
2x
            &snippet.UserID,
419
2x
            &snippet.OriginalText,
420
2x
            &snippet.TranslatedText,
421
2x
            &snippet.SourceLanguage,
422
2x
            &snippet.TargetLanguage,
423
2x
            &snippet.QuestionID,
424
2x
            &snippet.Context,
425
2x
            &snippet.DifficultyLevel,
426
2x
            &snippet.CreatedAt,
427
2x
            &snippet.UpdatedAt,
428
2x
        )
429
2x
        if err != nil {
430
            return nil, contextutils.WrapErrorf(err, "failed to scan snippet")
431
        }
432

433
2x
        snippets = append(snippets, api.Snippet{
434
2x
            Id:              &snippet.ID,
435
2x
            UserId:          &snippet.UserID,
436
2x
            OriginalText:    &snippet.OriginalText,
437
2x
            TranslatedText:  &snippet.TranslatedText,
438
2x
            SourceLanguage:  &snippet.SourceLanguage,
439
2x
            TargetLanguage:  &snippet.TargetLanguage,
440
2x
            QuestionId:      snippet.QuestionID,
441
2x
            Context:         snippet.Context,
442
2x
            DifficultyLevel: snippet.DifficultyLevel,
443
2x
            CreatedAt:       &snippet.CreatedAt,
444
2x
            UpdatedAt:       &snippet.UpdatedAt,
445
2x
        })
446
    }
447

448
3x
    if err = rows.Err(); err != nil {
449
        return nil, contextutils.WrapErrorf(err, "error iterating over snippet rows")
450
    }
451

452
3x
    return snippets, nil
453
}
454

455
// GetSnippetsBySection retrieves snippets for a user filtered by section ID
456
// This method is optimized for performance to support async loading in the UI
457
func (s *SnippetsService) GetSnippetsBySection(ctx context.Context, userID, sectionID int64) (result []api.Snippet, err error) {
458
    ctx, span := observability.TraceFunction(ctx, "snippets", "get_snippets_by_section")
459
    defer observability.FinishSpan(span, &err)
460

461
    // Check if database connection is valid
462
    if s.db == nil {
463
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
464
    }
465

466
    span.SetAttributes(
467
        observability.AttributeUserID(int(userID)),
468
        attribute.Int64("section.id", sectionID),
469
    )
470

471
    // Query snippets for this user and section
472
    // Uses the new idx_snippets_section_id index for performance
473
    query := `
474
        SELECT id, user_id, original_text, translated_text, source_language, target_language,
475
               question_id, section_id, story_id, context, difficulty_level, created_at, updated_at
476
        FROM snippets
477
        WHERE user_id = $1 AND section_id = $2
478
        ORDER BY created_at DESC`
479

480
    rows, err := s.db.QueryContext(ctx, query, userID, sectionID)
481
    if err != nil {
482
        return nil, contextutils.WrapErrorf(err, "failed to get snippets by section")
483
    }
484
    defer func() {
485
        if closeErr := rows.Close(); closeErr != nil {
486
            s.logger.Warn(ctx, "Failed to close rows", map[string]any{"error": closeErr.Error()})
487
        }
488
    }()
489

490
    snippets := []api.Snippet{}
491
    for rows.Next() {
492
        var snippet models.Snippet
493
        err := rows.Scan(
494
            &snippet.ID,
495
            &snippet.UserID,
496
            &snippet.OriginalText,
497
            &snippet.TranslatedText,
498
            &snippet.SourceLanguage,
499
            &snippet.TargetLanguage,
500
            &snippet.QuestionID,
501
            &snippet.SectionID,
502
            &snippet.StoryID,
503
            &snippet.Context,
504
            &snippet.DifficultyLevel,
505
            &snippet.CreatedAt,
506
            &snippet.UpdatedAt,
507
        )
508
        if err != nil {
509
            return nil, contextutils.WrapErrorf(err, "failed to scan snippet")
510
        }
511

512
        snippets = append(snippets, api.Snippet{
513
            Id:              &snippet.ID,
514
            UserId:          &snippet.UserID,
515
            OriginalText:    &snippet.OriginalText,
516
            TranslatedText:  &snippet.TranslatedText,
517
            SourceLanguage:  &snippet.SourceLanguage,
518
            TargetLanguage:  &snippet.TargetLanguage,
519
            QuestionId:      snippet.QuestionID,
520
            SectionId:       snippet.SectionID,
521
            StoryId:         snippet.StoryID,
522
            Context:         snippet.Context,
523
            DifficultyLevel: snippet.DifficultyLevel,
524
            CreatedAt:       &snippet.CreatedAt,
525
            UpdatedAt:       &snippet.UpdatedAt,
526
        })
527
    }
528

529
    if err = rows.Err(); err != nil {
530
        return nil, contextutils.WrapErrorf(err, "error iterating over snippet rows")
531
    }
532

533
    return snippets, nil
534
}
535

536
// GetSnippetsByStory retrieves snippets for a user filtered by story ID
537
// This method is optimized for performance to support async loading in the UI
538
func (s *SnippetsService) GetSnippetsByStory(ctx context.Context, userID, storyID int64) (result []api.Snippet, err error) {
539
    ctx, span := observability.TraceFunction(ctx, "snippets", "get_snippets_by_story")
540
    defer observability.FinishSpan(span, &err)
541

542
    // Check if database connection is valid
543
    if s.db == nil {
544
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
545
    }
546

547
    span.SetAttributes(
548
        observability.AttributeUserID(int(userID)),
549
        attribute.Int64("story.id", storyID),
550
    )
551

552
    // Query snippets for this user and story
553
    // Uses the new idx_snippets_story_id index for performance
554
    query := `
555
        SELECT id, user_id, original_text, translated_text, source_language, target_language,
556
               question_id, section_id, story_id, context, difficulty_level, created_at, updated_at
557
        FROM snippets
558
        WHERE user_id = $1 AND story_id = $2
559
        ORDER BY created_at DESC`
560

561
    rows, err := s.db.QueryContext(ctx, query, userID, storyID)
562
    if err != nil {
563
        return nil, contextutils.WrapErrorf(err, "failed to get snippets by story")
564
    }
565
    defer func() {
566
        if closeErr := rows.Close(); closeErr != nil {
567
            s.logger.Warn(ctx, "Failed to close rows", map[string]any{"error": closeErr.Error()})
568
        }
569
    }()
570

571
    snippets := []api.Snippet{}
572
    for rows.Next() {
573
        var snippet models.Snippet
574
        err := rows.Scan(
575
            &snippet.ID,
576
            &snippet.UserID,
577
            &snippet.OriginalText,
578
            &snippet.TranslatedText,
579
            &snippet.SourceLanguage,
580
            &snippet.TargetLanguage,
581
            &snippet.QuestionID,
582
            &snippet.SectionID,
583
            &snippet.StoryID,
584
            &snippet.Context,
585
            &snippet.DifficultyLevel,
586
            &snippet.CreatedAt,
587
            &snippet.UpdatedAt,
588
        )
589
        if err != nil {
590
            return nil, contextutils.WrapErrorf(err, "failed to scan snippet")
591
        }
592

593
        snippets = append(snippets, api.Snippet{
594
            Id:              &snippet.ID,
595
            UserId:          &snippet.UserID,
596
            OriginalText:    &snippet.OriginalText,
597
            TranslatedText:  &snippet.TranslatedText,
598
            SourceLanguage:  &snippet.SourceLanguage,
599
            TargetLanguage:  &snippet.TargetLanguage,
600
            QuestionId:      snippet.QuestionID,
601
            SectionId:       snippet.SectionID,
602
            StoryId:         snippet.StoryID,
603
            Context:         snippet.Context,
604
            DifficultyLevel: snippet.DifficultyLevel,
605
            CreatedAt:       &snippet.CreatedAt,
606
            UpdatedAt:       &snippet.UpdatedAt,
607
        })
608
    }
609

610
    if err = rows.Err(); err != nil {
611
        return nil, contextutils.WrapErrorf(err, "error iterating over snippet rows")
612
    }
613

614
    return snippets, nil
615
}
616

617
// SearchSnippets searches across all snippets for a user
618
3x
func (s *SnippetsService) SearchSnippets(ctx context.Context, userID int64, query string, limit, offset int, sourceLang *string) (result []api.Snippet, totalCount int, err error) {
619
3x
    ctx, span := observability.TraceFunction(ctx, "snippets", "search_snippets")
620
3x
    defer observability.FinishSpan(span, &err)
621
3x

622
3x
    // Check if database connection is valid
623
3x
    if s.db == nil {
624
        return nil, 0, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
625
    }
626

627
3x
    span.SetAttributes(observability.AttributeUserID(int(userID)))
628
3x

629
3x
    // Clean and prepare the search query
630
3x
    searchQuery := strings.TrimSpace(query)
631
3x
    if searchQuery == "" {
632
        return nil, 0, contextutils.WrapError(contextutils.ErrInvalidInput, "search query cannot be empty")
633
    }
634

635
    // Search in both original_text and translated_text
636
3x
    searchTerm := fmt.Sprintf("%%%s%%", strings.ToLower(searchQuery))
637
3x

638
3x
    // Get total count of matching snippets
639
3x
    totalQuery := `
640
3x
        SELECT COUNT(*)
641
3x
        FROM snippets
642
3x
        WHERE user_id = $1 AND (LOWER(original_text) LIKE $2 OR LOWER(translated_text) LIKE $3)`
643
3x

644
3x
    var total int
645
3x
    // Add optional source language filter
646
3x
    totalArgs := []any{userID, searchTerm, searchTerm}
647
3x
    if sourceLang != nil && *sourceLang != "" {
648
2x
        totalQuery += " AND source_language = $4"
649
2x
        totalArgs = append(totalArgs, *sourceLang)
650
2x
    }
651

652
3x
    err = s.db.QueryRowContext(ctx, totalQuery, totalArgs...).Scan(&total)
653
3x
    if err != nil {
654
        return nil, 0, contextutils.WrapErrorf(err, "failed to get total count for search")
655
    }
656

657
    // Get matching snippets
658
3x
    queryStr := `
659
3x
        SELECT id, user_id, original_text, translated_text, source_language, target_language,
660
3x
               question_id, section_id, story_id, context, difficulty_level, created_at, updated_at
661
3x
        FROM snippets
662
3x
        WHERE user_id = $1 AND (LOWER(original_text) LIKE $2 OR LOWER(translated_text) LIKE $3)`
663
3x
    args := []any{userID, searchTerm, searchTerm}
664
3x
    if sourceLang != nil && *sourceLang != "" {
665
2x
        queryStr += " AND source_language = $4"
666
2x
        args = append(args, *sourceLang)
667
2x
        queryStr += " ORDER BY created_at DESC LIMIT $5 OFFSET $6"
668
2x
        args = append(args, limit, offset)
669
2x
    } else {
670
1x
        queryStr += " ORDER BY created_at DESC LIMIT $4 OFFSET $5"
671
1x
        args = append(args, limit, offset)
672
1x
    }
673

674
3x
    rows, err := s.db.QueryContext(ctx, queryStr, args...)
675
3x
    if err != nil {
676
        return nil, 0, contextutils.WrapErrorf(err, "failed to search snippets")
677
    }
678
3x
    defer func() {
679
3x
        if closeErr := rows.Close(); closeErr != nil {
680
            s.logger.Warn(ctx, "Failed to close rows", map[string]any{"error": closeErr.Error()})
681
        }
682
    }()
683

684
3x
    snippets := []api.Snippet{}
685
3x
    for rows.Next() {
686
2x
        var snippet models.Snippet
687
2x
        err := rows.Scan(
688
2x
            &snippet.ID,
689
2x
            &snippet.UserID,
690
2x
            &snippet.OriginalText,
691
2x
            &snippet.TranslatedText,
692
2x
            &snippet.SourceLanguage,
693
2x
            &snippet.TargetLanguage,
694
2x
            &snippet.QuestionID,
695
2x
            &snippet.SectionID,
696
2x
            &snippet.StoryID,
697
2x
            &snippet.Context,
698
2x
            &snippet.DifficultyLevel,
699
2x
            &snippet.CreatedAt,
700
2x
            &snippet.UpdatedAt,
701
2x
        )
702
2x
        if err != nil {
703
            return nil, 0, contextutils.WrapErrorf(err, "failed to scan snippet")
704
        }
705

706
2x
        snippets = append(snippets, api.Snippet{
707
2x
            Id:              &snippet.ID,
708
2x
            UserId:          &snippet.UserID,
709
2x
            OriginalText:    &snippet.OriginalText,
710
2x
            TranslatedText:  &snippet.TranslatedText,
711
2x
            SourceLanguage:  &snippet.SourceLanguage,
712
2x
            TargetLanguage:  &snippet.TargetLanguage,
713
2x
            QuestionId:      snippet.QuestionID,
714
2x
            SectionId:       snippet.SectionID,
715
2x
            StoryId:         snippet.StoryID,
716
2x
            Context:         snippet.Context,
717
2x
            DifficultyLevel: snippet.DifficultyLevel,
718
2x
            CreatedAt:       &snippet.CreatedAt,
719
2x
            UpdatedAt:       &snippet.UpdatedAt,
720
2x
        })
721
    }
722

723
3x
    return snippets, total, nil
724
}
725

726
// snippetExists checks if a snippet already exists for the user
727
17x
func (s *SnippetsService) snippetExists(ctx context.Context, userID int64, originalText, sourceLanguage, targetLanguage string) (bool, error) {
728
17x
    ctx, span := observability.TraceFunction(ctx, "snippets", "snippet_exists")
729
17x
    defer observability.FinishSpan(span, nil)
730
17x

731
17x
    // Check if database connection is valid
732
17x
    if s.db == nil {
733
        return false, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
734
    }
735

736
17x
    span.SetAttributes(observability.AttributeUserID(int(userID)))
737
17x

738
17x
    query := `
739
17x
        SELECT COUNT(*)
740
17x
        FROM snippets
741
17x
        WHERE user_id = $1 AND original_text = $2 AND source_language = $3 AND target_language = $4`
742
17x

743
17x
    var count int
744
17x
    err := s.db.QueryRowContext(ctx, query, userID, originalText, sourceLanguage, targetLanguage).Scan(&count)
745
17x
    if err != nil {
746
        return false, contextutils.WrapErrorf(err, "failed to check snippet existence")
747
    }
748

749
17x
    return count > 0, nil
750
}
751

752
// GetSnippet retrieves a specific snippet by ID
753
3x
func (s *SnippetsService) GetSnippet(ctx context.Context, userID, snippetID int64) (result *models.Snippet, err error) {
754
3x
    ctx, span := observability.TraceFunction(ctx, "snippets", "get_snippet")
755
3x
    defer observability.FinishSpan(span, &err)
756
3x

757
3x
    // Check if database connection is valid
758
3x
    if s.db == nil {
759
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
760
    }
761

762
3x
    span.SetAttributes(observability.AttributeUserID(int(userID)))
763
3x
    span.SetAttributes(observability.AttributeSnippetID(int(snippetID)))
764
3x

765
3x
    query := `
766
3x
        SELECT id, user_id, original_text, translated_text, source_language, target_language,
767
3x
               question_id, context, difficulty_level, created_at, updated_at
768
3x
        FROM snippets
769
3x
        WHERE id = $1 AND user_id = $2`
770
3x

771
3x
    result = &models.Snippet{}
772
3x
    err = s.db.QueryRowContext(ctx, query, snippetID, userID).Scan(
773
3x
        &result.ID,
774
3x
        &result.UserID,
775
3x
        &result.OriginalText,
776
3x
        &result.TranslatedText,
777
3x
        &result.SourceLanguage,
778
3x
        &result.TargetLanguage,
779
3x
        &result.QuestionID,
780
3x
        &result.Context,
781
3x
        &result.DifficultyLevel,
782
3x
        &result.CreatedAt,
783
3x
        &result.UpdatedAt,
784
3x
    )
785
3x
    if err != nil {
786
2x
        if err == sql.ErrNoRows {
787
2x
            return nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "snippet not found")
788
2x
        }
789
        return nil, contextutils.WrapErrorf(err, "failed to get snippet")
790
    }
791

792
1x
    return result, nil
793
}
794

795
// UpdateSnippet updates a snippet's fields
796
2x
func (s *SnippetsService) UpdateSnippet(ctx context.Context, userID, snippetID int64, req api.UpdateSnippetRequest) (result *models.Snippet, err error) {
797
2x
    ctx, span := observability.TraceFunction(ctx, "snippets", "update_snippet")
798
2x
    defer observability.FinishSpan(span, &err)
799
2x

800
2x
    // Check if database connection is valid
801
2x
    if s.db == nil {
802
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
803
    }
804

805
2x
    span.SetAttributes(observability.AttributeUserID(int(userID)))
806
2x
    span.SetAttributes(observability.AttributeSnippetID(int(snippetID)))
807
2x

808
2x
    // Build dynamic query based on which fields are provided
809
2x
    setParts := []string{"updated_at = CURRENT_TIMESTAMP"}
810
2x
    args := []interface{}{}
811
2x
    argCount := 0
812
2x

813
2x
    if req.OriginalText != nil {
814
2x
        argCount++
815
2x
        setParts = append(setParts, fmt.Sprintf("original_text = $%d", argCount))
816
2x
        args = append(args, *req.OriginalText)
817
2x
    }
818

819
2x
    if req.TranslatedText != nil {
820
2x
        argCount++
821
2x
        setParts = append(setParts, fmt.Sprintf("translated_text = $%d", argCount))
822
2x
        args = append(args, *req.TranslatedText)
823
2x
    }
824

825
2x
    if req.SourceLanguage != nil {
826
2x
        argCount++
827
2x
        setParts = append(setParts, fmt.Sprintf("source_language = $%d", argCount))
828
2x
        args = append(args, *req.SourceLanguage)
829
2x
    }
830

831
2x
    if req.TargetLanguage != nil {
832
2x
        argCount++
833
2x
        setParts = append(setParts, fmt.Sprintf("target_language = $%d", argCount))
834
2x
        args = append(args, *req.TargetLanguage)
835
2x
    }
836

837
2x
    if req.Context != nil {
838
2x
        argCount++
839
2x
        setParts = append(setParts, fmt.Sprintf("context = $%d", argCount))
840
2x
        args = append(args, *req.Context)
841
2x
    }
842

843
2x
    if len(setParts) == 1 {
844
        // No fields to update
845
        return nil, contextutils.WrapError(contextutils.ErrInvalidInput, "no fields to update")
846
    }
847

848
    // Add WHERE clause parameters
849
2x
    argCount++
850
2x
    whereClause := fmt.Sprintf("WHERE id = $%d AND user_id = $%d", argCount, argCount+1)
851
2x
    args = append(args, snippetID, userID)
852
2x

853
2x
    query := fmt.Sprintf(`
854
2x
        UPDATE snippets
855
2x
        SET %s
856
2x
        %s
857
2x
        RETURNING id, user_id, original_text, translated_text, source_language, target_language,
858
2x
                  question_id, context, difficulty_level, created_at, updated_at`,
859
2x
        strings.Join(setParts, ", "), whereClause)
860
2x

861
2x
    result = &models.Snippet{}
862
2x
    err = s.db.QueryRowContext(ctx, query, args...).Scan(
863
2x
        &result.ID,
864
2x
        &result.UserID,
865
2x
        &result.OriginalText,
866
2x
        &result.TranslatedText,
867
2x
        &result.SourceLanguage,
868
2x
        &result.TargetLanguage,
869
2x
        &result.QuestionID,
870
2x
        &result.Context,
871
2x
        &result.DifficultyLevel,
872
2x
        &result.CreatedAt,
873
2x
        &result.UpdatedAt,
874
2x
    )
875
2x
    if err != nil {
876
1x
        if err == sql.ErrNoRows {
877
1x
            return nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "snippet not found")
878
1x
        }
879
        // Map unique constraint violations to conflict error (409)
880
        if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
881
            return nil, contextutils.WrapError(contextutils.ErrRecordExists, "a snippet with the same text and language already exists in this context")
882
        }
883
        return nil, contextutils.WrapErrorf(err, "failed to update snippet")
884
    }
885

886
1x
    s.logger.Info(ctx, "Updated snippet",
887
1x
        map[string]any{
888
1x
            "snippet_id": result.ID,
889
1x
            "user_id":    userID,
890
1x
        })
891
1x

892
1x
    return result, nil
893
}
894

895
// DeleteSnippet deletes a snippet
896
2x
func (s *SnippetsService) DeleteSnippet(ctx context.Context, userID, snippetID int64) (err error) {
897
2x
    ctx, span := observability.TraceFunction(ctx, "snippets", "delete_snippet")
898
2x
    defer observability.FinishSpan(span, &err)
899
2x

900
2x
    // Check if database connection is valid
901
2x
    if s.db == nil {
902
        return contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
903
    }
904

905
2x
    span.SetAttributes(observability.AttributeUserID(int(userID)))
906
2x
    span.SetAttributes(observability.AttributeSnippetID(int(snippetID)))
907
2x

908
2x
    result, err := s.db.ExecContext(ctx, "DELETE FROM snippets WHERE id = $1 AND user_id = $2", snippetID, userID)
909
2x
    if err != nil {
910
        return contextutils.WrapErrorf(err, "failed to delete snippet")
911
    }
912

913
2x
    rowsAffected, err := result.RowsAffected()
914
2x
    if err != nil {
915
        return contextutils.WrapErrorf(err, "failed to get rows affected")
916
    }
917

918
2x
    if rowsAffected == 0 {
919
1x
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "snippet not found")
920
1x
    }
921

922
1x
    s.logger.Info(ctx, "Deleted snippet",
923
1x
        map[string]any{
924
1x
            "snippet_id": snippetID,
925
1x
            "user_id":    userID,
926
1x
        })
927
1x

928
1x
    return nil
929
}
930

931
// DeleteAllSnippets deletes all snippets for a user
932
func (s *SnippetsService) DeleteAllSnippets(ctx context.Context, userID int64) (err error) {
933
    ctx, span := observability.TraceFunction(ctx, "snippets", "delete_all_snippets")
934
    defer observability.FinishSpan(span, &err)
935

936
    // Check if database connection is valid
937
    if s.db == nil {
938
        return contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
939
    }
940

941
    span.SetAttributes(observability.AttributeUserID(int(userID)))
942

943
    result, err := s.db.ExecContext(ctx, "DELETE FROM snippets WHERE user_id = $1", userID)
944
    if err != nil {
945
        return contextutils.WrapErrorf(err, "failed to delete all snippets for user")
946
    }
947

948
    rowsAffected, err := result.RowsAffected()
949
    if err != nil {
950
        return contextutils.WrapErrorf(err, "failed to get rows affected")
951
    }
952

953
    s.logger.Info(ctx, "Deleted all snippets for user",
954
        map[string]any{
955
            "user_id":          userID,
956
            "snippets_deleted": rowsAffected,
957
        })
958

959
    return nil
960
}
961


			
quizapp internal services worker_service.go
38.5%
Statements
255/663
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "encoding/json"
7
    "errors"
8
    "fmt"
9
    "strings"
10
    "time"
11

12
    "quizapp/internal/config"
13
    "quizapp/internal/models"
14
    "quizapp/internal/observability"
15
    contextutils "quizapp/internal/utils"
16

17
    "go.opentelemetry.io/otel/attribute"
18
)
19

20
// StoryServiceInterface defines the interface for story operations
21
type StoryServiceInterface interface {
22
    CreateStory(ctx context.Context, userID uint, language string, req *models.CreateStoryRequest) (*models.Story, error)
23
    GetUserStories(ctx context.Context, userID uint, includeArchived bool) ([]models.Story, error)
24
    GetCurrentStory(ctx context.Context, userID uint) (*models.StoryWithSections, error)
25
    GetStory(ctx context.Context, storyID, userID uint) (*models.StoryWithSections, error)
26
    ArchiveStory(ctx context.Context, storyID, userID uint) error
27
    CompleteStory(ctx context.Context, storyID, userID uint) error
28
    SetCurrentStory(ctx context.Context, storyID, userID uint) error
29
    ToggleAutoGeneration(ctx context.Context, storyID, userID uint, paused bool) error
30
    DeleteStory(ctx context.Context, storyID, userID uint) error
31
    DeleteAllStoriesForUser(ctx context.Context, userID uint) error
32
    FixCurrentStoryConstraint(ctx context.Context) error
33
    GetStorySections(ctx context.Context, storyID uint) ([]models.StorySection, error)
34
    GetSection(ctx context.Context, sectionID, userID uint) (*models.StorySectionWithQuestions, error)
35
    CreateSection(ctx context.Context, storyID uint, content, level string, wordCount int, generatedBy models.GeneratorType) (*models.StorySection, error)
36
    GetLatestSection(ctx context.Context, storyID uint) (*models.StorySection, error)
37
    GetAllSectionsText(ctx context.Context, storyID uint) (string, error)
38
    GetSectionQuestions(ctx context.Context, sectionID uint) ([]models.StorySectionQuestion, error)
39
    CreateSectionQuestions(ctx context.Context, sectionID uint, questions []models.StorySectionQuestionData) error
40
    GetRandomQuestions(ctx context.Context, sectionID uint, count int) ([]models.StorySectionQuestion, error)
41
    UpdateLastGenerationTime(ctx context.Context, storyID uint, generatorType models.GeneratorType) error
42
    RecordStorySectionView(ctx context.Context, userID, sectionID uint) error
43
    HasUserViewedLatestSection(ctx context.Context, userID uint) (bool, error)
44
    GetSectionLengthTarget(level string, lengthPref *models.SectionLength) int
45
    GetSectionLengthTargetWithLanguage(language, level string, lengthPref *models.SectionLength) int
46
    SanitizeInput(input string) string
47
    GenerateStorySection(ctx context.Context, storyID, userID uint, aiService AIServiceInterface, userAIConfig *models.UserAIConfig, generatorType models.GeneratorType) (*models.StorySectionWithQuestions, error)
48
    // Admin-only helpers (no ownership checks)
49
    GetStoriesPaginated(ctx context.Context, page, pageSize int, search, language, status string, userID *uint) ([]models.Story, int, error)
50
    GetStoryAdmin(ctx context.Context, storyID uint) (*models.StoryWithSections, error)
51
    GetSectionAdmin(ctx context.Context, sectionID uint) (*models.StorySectionWithQuestions, error)
52
    // Admin-only delete without ownership check
53
    DeleteStoryAdmin(ctx context.Context, storyID uint) error
54
}
55

56
// StoryService handles all story-related operations
57
type StoryService struct {
58
    db     *sql.DB
59
    config *config.Config
60
    logger *observability.Logger
61
}
62

63
// NewStoryService creates a new StoryService instance
64
17x
func NewStoryService(db *sql.DB, config *config.Config, logger *observability.Logger) *StoryService {
65
17x
    if db == nil {
66
1x
        panic("StoryService requires a valid database connection")
67
    }
68
15x
    return &StoryService{
69
15x
        db:     db,
70
15x
        config: config,
71
15x
        logger: logger,
72
15x
    }
73
}
74

75
// CreateStory creates a new story for the user
76
19x
func (s *StoryService) CreateStory(ctx context.Context, userID uint, language string, req *models.CreateStoryRequest) (*models.Story, error) {
77
19x
    if err := req.Validate(); err != nil {
78
        return nil, contextutils.WrapErrorf(err, "invalid story request")
79
    }
80

81
    // Check if user has reached the archived story limit
82
19x
    archivedCount, err := s.getArchivedStoryCount(ctx, userID)
83
19x
    if err != nil {
84
        return nil, contextutils.WrapErrorf(err, "failed to check archived story count")
85
    }
86

87
19x
    if archivedCount >= s.config.Story.MaxArchivedPerUser {
88
        return nil, contextutils.ErrorWithContextf("maximum archived stories limit reached (%d)", s.config.Story.MaxArchivedPerUser)
89
    }
90

91
    // Get user's current language level (stored for potential future use)
92
19x
    _, err = s.getUserCurrentLevel(ctx, userID)
93
19x
    if err != nil {
94
        return nil, contextutils.WrapErrorf(err, "failed to get user level")
95
    }
96

97
    // Unset any existing active story in the same language first
98
19x
    unsetQuery := "UPDATE stories SET status = $1, updated_at = NOW() WHERE user_id = $2 AND language = $3 AND status = $4"
99
19x
    _, err = s.db.ExecContext(ctx, unsetQuery, models.StoryStatusArchived, userID, language, models.StoryStatusActive)
100
19x
    if err != nil {
101
        return nil, contextutils.WrapErrorf(err, "failed to unset existing current story")
102
    }
103

104
    // Create the story
105
19x
    story := &models.Story{
106
19x
        UserID:                userID,
107
19x
        Title:                 req.Title,
108
19x
        Language:              language,
109
19x
        Subject:               req.Subject,
110
19x
        AuthorStyle:           req.AuthorStyle,
111
19x
        TimePeriod:            req.TimePeriod,
112
19x
        Genre:                 req.Genre,
113
19x
        Tone:                  req.Tone,
114
19x
        CharacterNames:        req.CharacterNames,
115
19x
        CustomInstructions:    req.CustomInstructions,
116
19x
        SectionLengthOverride: req.SectionLengthOverride,
117
19x
        Status:                models.StoryStatusActive,
118
19x
        CreatedAt:             time.Now(),
119
19x
        UpdatedAt:             time.Now(),
120
19x
    }
121
19x

122
19x
    if err := s.createStory(ctx, story); err != nil {
123
        return nil, contextutils.WrapErrorf(err, "failed to create story")
124
    }
125

126
19x
    s.logger.Info(context.Background(), "Story created successfully",
127
19x
        map[string]interface{}{
128
19x
            "story_id": story.ID,
129
19x
            "user_id":  userID,
130
19x
            "title":    story.Title,
131
19x
        })
132
19x

133
19x
    return story, nil
134
}
135

136
// GetUserStories retrieves all stories for a user in their preferred language
137
5x
func (s *StoryService) GetUserStories(ctx context.Context, userID uint, includeArchived bool) ([]models.Story, error) {
138
5x
    // Get user's preferred language
139
5x
    user, err := s.getUserByID(ctx, userID)
140
5x
    if err != nil {
141
        return nil, contextutils.WrapErrorf(err, "failed to get user")
142
    }
143

144
5x
    if user == nil {
145
1x
        // Return empty slice for non-existent user instead of error
146
1x
        return []models.Story{}, nil
147
1x
    }
148

149
4x
    language := "en" // default
150
4x
    if user.PreferredLanguage.Valid {
151
4x
        language = user.PreferredLanguage.String
152
4x
    }
153

154
4x
    query := `
155
4x
        SELECT id, user_id, title, language, subject, author_style, time_period, genre, tone,
156
4x
               character_names, custom_instructions, section_length_override, status,
157
4x
               auto_generation_paused, last_section_generated_at, created_at, updated_at
158
4x
        FROM stories
159
4x
        WHERE user_id = $1 AND language = $2`
160
4x

161
4x
    args := []interface{}{userID, language}
162
4x

163
4x
    if !includeArchived {
164
2x
        query += " AND status != $3"
165
2x
        args = append(args, models.StoryStatusArchived)
166
2x
    }
167

168
4x
    query += " ORDER BY status = 'active' DESC, created_at DESC"
169
4x

170
4x
    rows, err := s.db.QueryContext(ctx, query, args...)
171
4x
    if err != nil {
172
        return nil, err
173
    }
174
4x
    defer func() { _ = rows.Close() }()
175

176
4x
    stories := []models.Story{}
177
4x
    for rows.Next() {
178
5x
        var story models.Story
179
5x
        err := rows.Scan(
180
5x
            &story.ID, &story.UserID, &story.Title, &story.Language, &story.Subject,
181
5x
            &story.AuthorStyle, &story.TimePeriod, &story.Genre, &story.Tone,
182
5x
            &story.CharacterNames, &story.CustomInstructions, &story.SectionLengthOverride,
183
5x
            &story.Status, &story.AutoGenerationPaused,
184
5x
            &story.LastSectionGeneratedAt,
185
5x
            &story.CreatedAt, &story.UpdatedAt,
186
5x
        )
187
5x
        if err != nil {
188
            return nil, err
189
        }
190
5x
        stories = append(stories, story)
191
    }
192

193
4x
    return stories, rows.Err()
194
}
195

196
// GetCurrentStory retrieves the current active story for a user in their current language
197
17x
func (s *StoryService) GetCurrentStory(ctx context.Context, userID uint) (*models.StoryWithSections, error) {
198
17x
    // Get user's current language preference
199
17x
    user, err := s.getUserByID(ctx, userID)
200
17x
    if err != nil {
201
        return nil, contextutils.WrapErrorf(err, "failed to get user")
202
    }
203

204
17x
    if user == nil {
205
        return nil, contextutils.ErrorWithContextf("user not found: %d", userID)
206
    }
207

208
17x
    language := "en" // default
209
17x
    if user.PreferredLanguage.Valid {
210
17x
        language = user.PreferredLanguage.String
211
17x
    }
212

213
17x
    query := `
214
17x
        SELECT id, user_id, title, language, subject, author_style, time_period, genre, tone,
215
17x
               character_names, custom_instructions, section_length_override, status,
216
17x
               auto_generation_paused, last_section_generated_at, created_at, updated_at
217
17x
        FROM stories
218
17x
        WHERE user_id = $1 AND language = $2 AND status = $3 AND status != $4`
219
17x

220
17x
    var story models.Story
221
17x
    err = s.db.QueryRowContext(ctx, query, userID, language, models.StoryStatusActive, models.StoryStatusArchived).Scan(
222
17x
        &story.ID, &story.UserID, &story.Title, &story.Language, &story.Subject,
223
17x
        &story.AuthorStyle, &story.TimePeriod, &story.Genre, &story.Tone,
224
17x
        &story.CharacterNames, &story.CustomInstructions, &story.SectionLengthOverride,
225
17x
        &story.Status, &story.AutoGenerationPaused,
226
17x
        &story.LastSectionGeneratedAt,
227
17x
        &story.CreatedAt, &story.UpdatedAt,
228
17x
    )
229
17x
    if err != nil {
230
3x
        if errors.Is(err, sql.ErrNoRows) {
231
3x
            return nil, nil // No current story in user's language
232
3x
        }
233
        return nil, contextutils.WrapErrorf(err, "failed to get current story")
234
    }
235

236
    // Load sections
237
14x
    sections, err := s.GetStorySections(ctx, story.ID)
238
14x
    if err != nil {
239
        return nil, contextutils.WrapErrorf(err, "failed to load story sections")
240
    }
241

242
14x
    return &models.StoryWithSections{
243
14x
        Story:    story,
244
14x
        Sections: sections,
245
14x
    }, nil
246
}
247

248
// GetStory retrieves a specific story with ownership verification
249
func (s *StoryService) GetStory(ctx context.Context, storyID, userID uint) (*models.StoryWithSections, error) {
250
    query := `
251
        SELECT id, user_id, title, language, subject, author_style, time_period, genre, tone,
252
               character_names, custom_instructions, section_length_override, status,
253
               auto_generation_paused, last_section_generated_at, created_at, updated_at
254
        FROM stories
255
        WHERE id = $1 AND user_id = $2`
256

257
    var story models.Story
258
    err := s.db.QueryRowContext(ctx, query, storyID, userID).Scan(
259
        &story.ID, &story.UserID, &story.Title, &story.Language, &story.Subject,
260
        &story.AuthorStyle, &story.TimePeriod, &story.Genre, &story.Tone,
261
        &story.CharacterNames, &story.CustomInstructions, &story.SectionLengthOverride,
262
        &story.Status, &story.AutoGenerationPaused,
263
        &story.LastSectionGeneratedAt,
264
        &story.CreatedAt, &story.UpdatedAt,
265
    )
266
    if err != nil {
267
        if errors.Is(err, sql.ErrNoRows) {
268
            return nil, contextutils.ErrorWithContextf("story not found or access denied")
269
        }
270
        return nil, contextutils.WrapErrorf(err, "failed to get story")
271
    }
272

273
    // Load sections
274
    sections, err := s.GetStorySections(ctx, story.ID)
275
    if err != nil {
276
        return nil, contextutils.WrapErrorf(err, "failed to load story sections")
277
    }
278

279
    return &models.StoryWithSections{
280
        Story:    story,
281
        Sections: sections,
282
    }, nil
283
}
284

285
// ArchiveStory changes a story's status to archived
286
2x
func (s *StoryService) ArchiveStory(ctx context.Context, storyID, userID uint) error {
287
2x
    if err := s.validateStoryOwnership(ctx, storyID, userID); err != nil {
288
        return err
289
    }
290

291
    // First, check if the story is completed (completed stories cannot be archived)
292
2x
    var status string
293
2x
    checkQuery := "SELECT status FROM stories WHERE id = $1"
294
2x
    err := s.db.QueryRowContext(ctx, checkQuery, storyID).Scan(&status)
295
2x
    if err != nil {
296
        return contextutils.WrapErrorf(err, "failed to check story status")
297
    }
298

299
    // Prevent archiving completed stories
300
2x
    if status == string(models.StoryStatusCompleted) {
301
        return contextutils.ErrorWithContextf("cannot archive completed stories")
302
    }
303

304
    // Archive the story (this automatically removes it from being current since only active stories are current)
305
2x
    query := "UPDATE stories SET status = $1, updated_at = NOW() WHERE id = $2"
306
2x
    _, err = s.db.ExecContext(ctx, query, models.StoryStatusArchived, storyID)
307
2x
    if err != nil {
308
        return contextutils.WrapErrorf(err, "failed to archive story")
309
    }
310

311
2x
    s.logger.Info(context.Background(), "Story archived successfully",
312
2x
        map[string]interface{}{
313
2x
            "story_id": storyID,
314
2x
            "user_id":  userID,
315
2x
        })
316
2x

317
2x
    return nil
318
}
319

320
// CompleteStory changes a story's status to completed
321
func (s *StoryService) CompleteStory(ctx context.Context, storyID, userID uint) error {
322
    if err := s.validateStoryOwnership(ctx, storyID, userID); err != nil {
323
        return err
324
    }
325

326
    query := "UPDATE stories SET status = $1, updated_at = NOW() WHERE id = $2"
327
    _, err := s.db.ExecContext(ctx, query, models.StoryStatusCompleted, storyID)
328
    if err != nil {
329
        return contextutils.WrapErrorf(err, "failed to complete story")
330
    }
331

332
    s.logger.Info(context.Background(), "Story completed successfully",
333
        map[string]interface{}{
334
            "story_id": storyID,
335
            "user_id":  userID,
336
        })
337

338
    return nil
339
}
340

341
// ToggleAutoGeneration toggles the auto-generation pause state for a story
342
func (s *StoryService) ToggleAutoGeneration(ctx context.Context, storyID, userID uint, paused bool) error {
343
    if err := s.validateStoryOwnership(ctx, storyID, userID); err != nil {
344
        return err
345
    }
346

347
    query := "UPDATE stories SET auto_generation_paused = $1, updated_at = NOW() WHERE id = $2"
348
    _, err := s.db.ExecContext(ctx, query, paused, storyID)
349
    if err != nil {
350
        return contextutils.WrapErrorf(err, "failed to toggle auto-generation")
351
    }
352

353
    s.logger.Info(context.Background(), "Story auto-generation toggled",
354
        map[string]interface{}{
355
            "story_id": storyID,
356
            "user_id":  userID,
357
            "paused":   paused,
358
        })
359

360
    return nil
361
}
362

363
// SetCurrentStory sets a story as the current active story for the user in its language
364
func (s *StoryService) SetCurrentStory(ctx context.Context, storyID, userID uint) error {
365
    if err := s.validateStoryOwnership(ctx, storyID, userID); err != nil {
366
        return err
367
    }
368

369
    // Get the story's language and status
370
    query := "SELECT language, status FROM stories WHERE id = $1 AND user_id = $2"
371
    var language string
372
    var storyStatus models.StoryStatus
373
    err := s.db.QueryRowContext(ctx, query, storyID, userID).Scan(&language, &storyStatus)
374
    if err != nil {
375
        if errors.Is(err, sql.ErrNoRows) {
376
            return contextutils.ErrorWithContextf("story not found or access denied")
377
        }
378
        return contextutils.WrapErrorf(err, "failed to get story language and status")
379
    }
380

381
    // Only allow restoring active stories (not completed ones)
382
    if storyStatus == models.StoryStatusCompleted {
383
        return contextutils.ErrorWithContextf("cannot restore completed stories")
384
    }
385

386
    // Get the user's preferred language
387
    user, err := s.getUserByID(ctx, userID)
388
    if err != nil {
389
        return contextutils.WrapErrorf(err, "failed to get user")
390
    }
391

392
    if user == nil {
393
        return contextutils.ErrorWithContextf("user not found")
394
    }
395

396
    userPreferredLanguage := "en" // default
397
    if user.PreferredLanguage.Valid {
398
        userPreferredLanguage = user.PreferredLanguage.String
399
    }
400

401
    // Check if the story's language matches the user's preferred language
402
    if language != userPreferredLanguage {
403
        return contextutils.ErrorWithContextf("cannot restore story in different language than preferred language")
404
    }
405

406
    // Archive any existing active story in the same language for this user
407
    // (since only one story can be active per user per language)
408
    unsetQuery := "UPDATE stories SET status = $1, updated_at = NOW() WHERE user_id = $2 AND language = $3 AND status = $4"
409
    _, err = s.db.ExecContext(ctx, unsetQuery, models.StoryStatusArchived, userID, language, models.StoryStatusActive)
410
    if err != nil {
411
        return contextutils.WrapErrorf(err, "failed to unset existing active story")
412
    }
413

414
    // Set the specified story as active (which makes it current)
415
    setQuery := "UPDATE stories SET status = $1, updated_at = NOW() WHERE id = $2"
416
    _, err = s.db.ExecContext(ctx, setQuery, models.StoryStatusActive, storyID)
417
    if err != nil {
418
        return contextutils.WrapErrorf(err, "failed to set current story")
419
    }
420

421
    return nil
422
}
423

424
// FixCurrentStoryConstraint fixes any constraint violations where multiple stories are marked as active for the same user in the same language
425
func (s *StoryService) FixCurrentStoryConstraint(ctx context.Context) error {
426
    // Find all users who have multiple active stories in the same language
427
    query := `
428
        SELECT user_id, language, COUNT(*) as active_count
429
        FROM stories
430
        WHERE status = 'active'
431
        GROUP BY user_id, language
432
        HAVING COUNT(*) > 1`
433

434
    rows, err := s.db.QueryContext(ctx, query)
435
    if err != nil {
436
        return contextutils.WrapErrorf(err, "failed to find users with multiple active stories in same language")
437
    }
438
    defer func() { _ = rows.Close() }()
439

440
    for rows.Next() {
441
        var userID uint
442
        var language string
443
        var activeCount int
444

445
        if err := rows.Scan(&userID, &language, &activeCount); err != nil {
446
            return contextutils.WrapErrorf(err, "failed to scan user constraint violation")
447
        }
448

449
        // Fix constraint violation for this user and language
450
        if err := s.fixUserCurrentStoryConstraint(ctx, userID, language); err != nil {
451
            return contextutils.WrapErrorf(err, "failed to fix constraint for user %d in language %s", userID, language)
452
        }
453
    }
454

455
    return rows.Err()
456
}
457

458
// fixUserCurrentStoryConstraint fixes constraint violations for a specific user in a specific language
459
func (s *StoryService) fixUserCurrentStoryConstraint(ctx context.Context, userID uint, language string) error {
460
    tx, err := s.db.BeginTx(ctx, nil)
461
    if err != nil {
462
        return contextutils.WrapErrorf(err, "failed to begin transaction")
463
    }
464
    defer func() { _ = tx.Rollback() }()
465

466
    // Find all active stories for this user in this language, ordered by most recently updated
467
    var activeStories []uint
468
    selectQuery := `
469
        SELECT id FROM stories
470
        WHERE user_id = $1 AND language = $2 AND status = 'active'
471
        ORDER BY updated_at DESC`
472

473
    rows, err := tx.QueryContext(ctx, selectQuery, userID, language)
474
    if err != nil {
475
        return contextutils.WrapErrorf(err, "failed to find active stories for user in language")
476
    }
477
    defer func() { _ = rows.Close() }()
478

479
    for rows.Next() {
480
        var storyID uint
481
        if err := rows.Scan(&storyID); err != nil {
482
            return contextutils.WrapErrorf(err, "failed to scan story ID")
483
        }
484
        activeStories = append(activeStories, storyID)
485
    }
486

487
    if len(activeStories) <= 1 {
488
        // No constraint violation for this user in this language
489
        return tx.Commit()
490
    }
491

492
    // Archive all active stories except the most recently updated one
493
    for i := 1; i < len(activeStories); i++ {
494
        unsetQuery := "UPDATE stories SET status = $1, updated_at = NOW() WHERE id = $2"
495
        _, err = tx.ExecContext(ctx, unsetQuery, models.StoryStatusArchived, activeStories[i])
496
        if err != nil {
497
            return contextutils.WrapErrorf(err, "failed to unset active story %d", activeStories[i])
498
        }
499
    }
500

501
    return tx.Commit()
502
}
503

504
// DeleteStory permanently deletes a story (only allowed for archived stories)
505
func (s *StoryService) DeleteStory(ctx context.Context, storyID, userID uint) error {
506
    // Verify story exists and user owns it
507
    query := `
508
        SELECT id, user_id, title, language, subject, author_style, time_period, genre, tone,
509
               character_names, custom_instructions, section_length_override, status,
510
               last_section_generated_at, created_at, updated_at
511
        FROM stories
512
        WHERE id = $1 AND user_id = $2`
513

514
    var story models.Story
515
    err := s.db.QueryRowContext(ctx, query, storyID, userID).Scan(
516
        &story.ID, &story.UserID, &story.Title, &story.Language, &story.Subject,
517
        &story.AuthorStyle, &story.TimePeriod, &story.Genre, &story.Tone,
518
        &story.CharacterNames, &story.CustomInstructions, &story.SectionLengthOverride,
519
        &story.Status, &story.LastSectionGeneratedAt,
520
        &story.CreatedAt, &story.UpdatedAt,
521
    )
522
    if err != nil {
523
        if errors.Is(err, sql.ErrNoRows) {
524
            return contextutils.ErrorWithContextf("story not found or access denied")
525
        }
526
        return contextutils.WrapErrorf(err, "failed to get story")
527
    }
528

529
    // Only allow deletion of archived or completed stories
530
    if story.Status != models.StoryStatusArchived && story.Status != models.StoryStatusCompleted {
531
        return contextutils.ErrorWithContextf("cannot delete active story")
532
    }
533

534
    // Use transaction for atomic deletion
535
    tx, err := s.db.BeginTx(ctx, nil)
536
    if err != nil {
537
        return contextutils.WrapErrorf(err, "failed to begin transaction")
538
    }
539
    defer func() { _ = tx.Rollback() }()
540

541
    // Delete questions first (due to foreign key constraints)
542
    query1 := "DELETE FROM story_section_questions WHERE section_id IN (SELECT id FROM story_sections WHERE story_id = $1)"
543
    _, err = tx.ExecContext(ctx, query1, storyID)
544
    if err != nil {
545
        return contextutils.WrapErrorf(err, "failed to delete story questions")
546
    }
547

548
    // Delete sections
549
    query2 := "DELETE FROM story_sections WHERE story_id = $1"
550
    _, err = tx.ExecContext(ctx, query2, storyID)
551
    if err != nil {
552
        return contextutils.WrapErrorf(err, "failed to delete story sections")
553
    }
554

555
    // Delete story
556
    query3 := "DELETE FROM stories WHERE id = $1"
557
    _, err = tx.ExecContext(ctx, query3, storyID)
558
    if err != nil {
559
        return contextutils.WrapErrorf(err, "failed to delete story")
560
    }
561

562
    return tx.Commit()
563
}
564

565
// DeleteStoryAdmin permanently deletes a story by ID without ownership checks (admin-only).
566
// Admins can delete stories regardless of status, but regular users cannot delete active stories.
567
func (s *StoryService) DeleteStoryAdmin(ctx context.Context, storyID uint) error {
568
    // Verify story exists
569
    query := `
570
        SELECT id, user_id, title, language, subject, author_style, time_period, genre, tone,
571
               character_names, custom_instructions, section_length_override, status,
572
               last_section_generated_at, created_at, updated_at
573
        FROM stories
574
        WHERE id = $1`
575

576
    var story models.Story
577
    if err := s.db.QueryRowContext(ctx, query, storyID).Scan(
578
        &story.ID, &story.UserID, &story.Title, &story.Language, &story.Subject,
579
        &story.AuthorStyle, &story.TimePeriod, &story.Genre, &story.Tone,
580
        &story.CharacterNames, &story.CustomInstructions, &story.SectionLengthOverride,
581
        &story.Status, &story.LastSectionGeneratedAt,
582
        &story.CreatedAt, &story.UpdatedAt,
583
    ); err != nil {
584
        if errors.Is(err, sql.ErrNoRows) {
585
            return contextutils.ErrorWithContextf("story not found")
586
        }
587
        return contextutils.WrapErrorf(err, "failed to get story")
588
    }
589

590
    // Admin can delete any story regardless of status
591

592
    // Use transaction for atomic deletion
593
    tx, err := s.db.BeginTx(ctx, nil)
594
    if err != nil {
595
        return contextutils.WrapErrorf(err, "failed to begin transaction")
596
    }
597
    defer func() { _ = tx.Rollback() }()
598

599
    // Delete questions first (due to foreign key constraints)
600
    if _, err := tx.ExecContext(ctx, "DELETE FROM story_section_questions WHERE section_id IN (SELECT id FROM story_sections WHERE story_id = $1)", storyID); err != nil {
601
        return contextutils.WrapErrorf(err, "failed to delete story questions")
602
    }
603

604
    // Delete sections
605
    if _, err := tx.ExecContext(ctx, "DELETE FROM story_sections WHERE story_id = $1", storyID); err != nil {
606
        return contextutils.WrapErrorf(err, "failed to delete story sections")
607
    }
608

609
    // Delete story
610
    if _, err := tx.ExecContext(ctx, "DELETE FROM stories WHERE id = $1", storyID); err != nil {
611
        return contextutils.WrapErrorf(err, "failed to delete story")
612
    }
613

614
    return tx.Commit()
615
}
616

617
// DeleteAllStoriesForUser deletes all stories (and their sections/questions) for a given user
618
func (s *StoryService) DeleteAllStoriesForUser(ctx context.Context, userID uint) error {
619
    tx, err := s.db.BeginTx(ctx, nil)
620
    if err != nil {
621
        return contextutils.WrapErrorf(err, "failed to begin transaction")
622
    }
623
    defer func() { _ = tx.Rollback() }()
624

625
    // Delete questions for all sections belonging to stories of this user
626
    q1 := `DELETE FROM story_section_questions WHERE section_id IN (SELECT id FROM story_sections WHERE story_id IN (SELECT id FROM stories WHERE user_id = $1))`
627
    if _, err := tx.ExecContext(ctx, q1, userID); err != nil {
628
        return contextutils.WrapErrorf(err, "failed to delete story questions for user %d", userID)
629
    }
630

631
    // Delete sections for all stories belonging to this user
632
    q2 := `DELETE FROM story_sections WHERE story_id IN (SELECT id FROM stories WHERE user_id = $1)`
633
    if _, err := tx.ExecContext(ctx, q2, userID); err != nil {
634
        return contextutils.WrapErrorf(err, "failed to delete story sections for user %d", userID)
635
    }
636

637
    // Finally delete stories
638
    q3 := `DELETE FROM stories WHERE user_id = $1`
639
    if _, err := tx.ExecContext(ctx, q3, userID); err != nil {
640
        return contextutils.WrapErrorf(err, "failed to delete stories for user %d", userID)
641
    }
642

643
    if err := tx.Commit(); err != nil {
644
        return contextutils.WrapErrorf(err, "failed to commit delete all stories transaction for user %d", userID)
645
    }
646

647
    s.logger.Info(context.Background(), "Deleted all stories for user", map[string]interface{}{"user_id": userID})
648
    return nil
649
}
650

651
// GetStorySections retrieves all sections for a story
652
18x
func (s *StoryService) GetStorySections(ctx context.Context, storyID uint) ([]models.StorySection, error) {
653
18x
    query := `
654
18x
        SELECT id, story_id, section_number, content, language_level, word_count,
655
18x
               generated_by, generated_at, generation_date
656
18x
        FROM story_sections
657
18x
        WHERE story_id = $1
658
18x
        ORDER BY section_number ASC`
659
18x

660
18x
    rows, err := s.db.QueryContext(ctx, query, storyID)
661
18x
    if err != nil {
662
        return nil, contextutils.WrapErrorf(err, "failed to get story sections")
663
    }
664
18x
    defer func() { _ = rows.Close() }()
665

666
18x
    sections := make([]models.StorySection, 0)
667
18x
    for rows.Next() {
668
14x
        var section models.StorySection
669
14x
        err := rows.Scan(
670
14x
            &section.ID, &section.StoryID, &section.SectionNumber, &section.Content,
671
14x
            &section.LanguageLevel, &section.WordCount, &section.GeneratedBy, &section.GeneratedAt, &section.GenerationDate,
672
14x
        )
673
14x
        if err != nil {
674
            return nil, contextutils.WrapErrorf(err, "failed to scan story section")
675
        }
676
14x
        sections = append(sections, section)
677
    }
678

679
18x
    return sections, rows.Err()
680
}
681

682
// GetSection retrieves a specific section with ownership verification
683
3x
func (s *StoryService) GetSection(ctx context.Context, sectionID, userID uint) (*models.StorySectionWithQuestions, error) {
684
3x
    query := `
685
3x
        SELECT ss.id, ss.story_id, ss.section_number, ss.content, ss.language_level, ss.word_count,
686
3x
               ss.generated_by, ss.generated_at, ss.generation_date
687
3x
        FROM story_sections ss
688
3x
        JOIN stories s ON ss.story_id = s.id
689
3x
        WHERE ss.id = $1 AND s.user_id = $2`
690
3x

691
3x
    var section models.StorySection
692
3x
    err := s.db.QueryRowContext(ctx, query, sectionID, userID).Scan(
693
3x
        &section.ID, &section.StoryID, &section.SectionNumber, &section.Content,
694
3x
        &section.LanguageLevel, &section.WordCount, &section.GeneratedBy, &section.GeneratedAt, &section.GenerationDate,
695
3x
    )
696
3x
    if err != nil {
697
2x
        if errors.Is(err, sql.ErrNoRows) {
698
2x
            return nil, contextutils.ErrorWithContextf("section not found or access denied")
699
2x
        }
700
        return nil, contextutils.WrapErrorf(err, "failed to get section")
701
    }
702

703
    // Load questions
704
1x
    questions, err := s.GetSectionQuestions(ctx, section.ID)
705
1x
    if err != nil {
706
        return nil, contextutils.WrapErrorf(err, "failed to load section questions")
707
    }
708

709
1x
    return &models.StorySectionWithQuestions{
710
1x
        StorySection: section,
711
1x
        Questions:    questions,
712
1x
    }, nil
713
}
714

715
// CreateSection adds a new section to a story
716
22x
func (s *StoryService) CreateSection(ctx context.Context, storyID uint, content, level string, wordCount int, generatedBy models.GeneratorType) (*models.StorySection, error) {
717
22x
    // Get the next section number
718
22x
    var maxSectionNumber int
719
22x
    query := "SELECT COALESCE(MAX(section_number), 0) FROM story_sections WHERE story_id = $1"
720
22x
    err := s.db.QueryRowContext(ctx, query, storyID).Scan(&maxSectionNumber)
721
22x
    if err != nil {
722
        return nil, contextutils.WrapErrorf(err, "failed to get max section number")
723
    }
724

725
22x
    section := &models.StorySection{
726
22x
        StoryID:        storyID,
727
22x
        SectionNumber:  maxSectionNumber + 1,
728
22x
        Content:        content,
729
22x
        LanguageLevel:  level,
730
22x
        WordCount:      wordCount,
731
22x
        GeneratedBy:    generatedBy,
732
22x
        GeneratedAt:    time.Now(),
733
22x
        GenerationDate: time.Now().Truncate(24 * time.Hour),
734
22x
    }
735
22x

736
22x
    if err := s.createSection(ctx, section); err != nil {
737
        return nil, contextutils.WrapErrorf(err, "failed to create section")
738
    }
739

740
22x
    return section, nil
741
}
742

743
// GetLatestSection retrieves the most recent section for a story
744
3x
func (s *StoryService) GetLatestSection(ctx context.Context, storyID uint) (*models.StorySection, error) {
745
3x
    query := `
746
3x
        SELECT id, story_id, section_number, content, language_level, word_count,
747
3x
               generated_by, generated_at, generation_date
748
3x
        FROM story_sections
749
3x
        WHERE story_id = $1
750
3x
        ORDER BY section_number DESC
751
3x
        LIMIT 1`
752
3x

753
3x
    var section models.StorySection
754
3x
    err := s.db.QueryRowContext(ctx, query, storyID).Scan(
755
3x
        &section.ID, &section.StoryID, &section.SectionNumber, &section.Content,
756
3x
        &section.LanguageLevel, &section.WordCount, &section.GeneratedBy, &section.GeneratedAt, &section.GenerationDate,
757
3x
    )
758
3x
    if err != nil {
759
1x
        if errors.Is(err, sql.ErrNoRows) {
760
1x
            return nil, nil // No sections yet
761
1x
        }
762
        return nil, contextutils.WrapErrorf(err, "failed to get latest section")
763
    }
764

765
2x
    return &section, nil
766
}
767

768
// GetAllSectionsText concatenates all section content for AI context
769
func (s *StoryService) GetAllSectionsText(ctx context.Context, storyID uint) (string, error) {
770
    sections, err := s.GetStorySections(ctx, storyID)
771
    if err != nil {
772
        return "", err
773
    }
774

775
    var sectionsText strings.Builder
776
    for i, section := range sections {
777
        if i > 0 {
778
            sectionsText.WriteString("\n\n")
779
        }
780
        sectionsText.WriteString(fmt.Sprintf("Section %d:\n%s", section.SectionNumber, section.Content))
781
    }
782

783
    return sectionsText.String(), nil
784
}
785

786
// GetSectionQuestions retrieves all questions for a section
787
2x
func (s *StoryService) GetSectionQuestions(ctx context.Context, sectionID uint) ([]models.StorySectionQuestion, error) {
788
2x
    query := `
789
2x
        SELECT id, section_id, question_text, options, correct_answer_index, explanation, created_at
790
2x
        FROM story_section_questions
791
2x
        WHERE section_id = $1`
792
2x

793
2x
    rows, err := s.db.QueryContext(ctx, query, sectionID)
794
2x
    if err != nil {
795
        return nil, contextutils.WrapErrorf(err, "failed to get section questions")
796
    }
797
2x
    defer func() { _ = rows.Close() }()
798

799
2x
    questions := []models.StorySectionQuestion{}
800
2x
    for rows.Next() {
801
5x
        var question models.StorySectionQuestion
802
5x
        var optionsJSON []byte
803
5x

804
5x
        err := rows.Scan(
805
5x
            &question.ID, &question.SectionID, &question.QuestionText, &optionsJSON,
806
5x
            &question.CorrectAnswerIndex, &question.Explanation, &question.CreatedAt,
807
5x
        )
808
5x
        if err != nil {
809
            return nil, contextutils.WrapErrorf(err, "failed to scan question")
810
        }
811

812
        // Unmarshal JSON options back to []string
813
5x
        err = json.Unmarshal(optionsJSON, &question.Options)
814
5x
        if err != nil {
815
            return nil, contextutils.WrapErrorf(err, "failed to unmarshal options from JSON")
816
        }
817

818
5x
        questions = append(questions, question)
819
    }
820

821
2x
    return questions, rows.Err()
822
}
823

824
// CreateSectionQuestions bulk inserts questions for a section
825
2x
func (s *StoryService) CreateSectionQuestions(ctx context.Context, sectionID uint, questions []models.StorySectionQuestionData) error {
826
2x
    if len(questions) == 0 {
827
        return nil
828
    }
829

830
2x
    tx, err := s.db.BeginTx(ctx, nil)
831
2x
    if err != nil {
832
        return contextutils.WrapErrorf(err, "failed to begin transaction")
833
    }
834
2x
    defer func() { _ = tx.Rollback() }()
835

836
2x
    for _, q := range questions {
837
5x
        query := `
838
5x
            INSERT INTO story_section_questions (
839
5x
                section_id, question_text, options, correct_answer_index, explanation, created_at
840
5x
            ) VALUES ($1, $2, $3, $4, $5, $6)`
841
5x

842
5x
        // Convert []string options to JSON for PostgreSQL JSONB column
843
5x
        optionsJSON, err := json.Marshal(q.Options)
844
5x
        if err != nil {
845
            return contextutils.WrapErrorf(err, "failed to marshal options to JSON")
846
        }
847

848
5x
        _, err = tx.ExecContext(ctx, query,
849
5x
            sectionID, q.QuestionText, optionsJSON, q.CorrectAnswerIndex, q.Explanation, time.Now(),
850
5x
        )
851
5x
        if err != nil {
852
            return contextutils.WrapErrorf(err, "failed to insert question")
853
        }
854
    }
855

856
2x
    return tx.Commit()
857
}
858

859
// createSectionQuestionsInTx creates questions within an existing database transaction
860
func (s *StoryService) createSectionQuestionsInTx(ctx context.Context, tx *sql.Tx, sectionID uint, questions []models.StorySectionQuestionData) error {
861
    if len(questions) == 0 {
862
        return nil
863
    }
864

865
    for _, q := range questions {
866
        query := `
867
            INSERT INTO story_section_questions (
868
                section_id, question_text, options, correct_answer_index, explanation, created_at
869
            ) VALUES ($1, $2, $3, $4, $5, $6)`
870

871
        // Convert []string options to JSON for PostgreSQL JSONB column
872
        optionsJSON, err := json.Marshal(q.Options)
873
        if err != nil {
874
            return contextutils.WrapErrorf(err, "failed to marshal options to JSON")
875
        }
876

877
        _, err = tx.ExecContext(ctx, query,
878
            sectionID, q.QuestionText, optionsJSON, q.CorrectAnswerIndex, q.Explanation, time.Now(),
879
        )
880
        if err != nil {
881
            return contextutils.WrapErrorf(err, "failed to insert question")
882
        }
883
    }
884

885
    return nil
886
}
887

888
// GetRandomQuestions retrieves N random questions for a section
889
1x
func (s *StoryService) GetRandomQuestions(ctx context.Context, sectionID uint, count int) ([]models.StorySectionQuestion, error) {
890
1x
    query := `
891
1x
        SELECT id, section_id, question_text, options, correct_answer_index, explanation, created_at
892
1x
        FROM story_section_questions
893
1x
        WHERE section_id = $1
894
1x
        ORDER BY RANDOM()
895
1x
        LIMIT $2`
896
1x

897
1x
    rows, err := s.db.QueryContext(ctx, query, sectionID, count)
898
1x
    if err != nil {
899
        return nil, contextutils.WrapErrorf(err, "failed to get random questions")
900
    }
901
1x
    defer func() { _ = rows.Close() }()
902

903
1x
    questions := []models.StorySectionQuestion{}
904
1x
    for rows.Next() {
905
2x
        var question models.StorySectionQuestion
906
2x
        var optionsJSON []byte
907
2x

908
2x
        err := rows.Scan(
909
2x
            &question.ID, &question.SectionID, &question.QuestionText, &optionsJSON,
910
2x
            &question.CorrectAnswerIndex, &question.Explanation, &question.CreatedAt,
911
2x
        )
912
2x
        if err != nil {
913
            return nil, contextutils.WrapErrorf(err, "failed to scan question")
914
        }
915

916
        // Unmarshal JSON options back to []string
917
2x
        err = json.Unmarshal(optionsJSON, &question.Options)
918
2x
        if err != nil {
919
            return nil, contextutils.WrapErrorf(err, "failed to unmarshal options from JSON")
920
        }
921

922
2x
        questions = append(questions, question)
923
    }
924

925
1x
    return questions, rows.Err()
926
}
927

928
// canGenerateSection checks if a new section can be generated for a story today by a specific generator
929
19x
func (s *StoryService) canGenerateSection(ctx context.Context, storyID uint, generatorType models.GeneratorType) (response *models.StoryGenerationEligibilityResponse, err error) {
930
19x
    ctx, span := observability.TraceFunction(ctx, "story_service", "can_generate_section",
931
19x
        attribute.Int("story.id", int(storyID)),
932
19x
        observability.AttributeGenerationType(generatorType),
933
19x
    )
934
19x
    defer observability.FinishSpan(span, &err)
935
19x

936
19x
    query := `
937
19x
        SELECT status, last_section_generated_at, extra_generations_today
938
19x
        FROM stories
939
19x
        WHERE id = $1`
940
19x

941
19x
    var status string
942
19x
    var lastGen *time.Time
943
19x
    var extraGenerationsToday int
944
19x

945
19x
    err = s.db.QueryRowContext(ctx, query, storyID).Scan(&status, &lastGen, &extraGenerationsToday)
946
19x
    if err != nil {
947
        if errors.Is(err, sql.ErrNoRows) {
948
            return &models.StoryGenerationEligibilityResponse{
949
                CanGenerate: false,
950
                Reason:      "story not found",
951
            }, nil
952
        }
953
        return nil, contextutils.WrapErrorf(err, "failed to get story")
954
    }
955

956
    // Check if story generation is enabled globally
957
19x
    if !s.config.Story.GenerationEnabled {
958
        return &models.StoryGenerationEligibilityResponse{
959
            CanGenerate: false,
960
            Reason:      "story generation is disabled globally",
961
        }, nil
962
    }
963

964
    // Check if story is active (active stories are by definition current)
965
19x
    if status != string(models.StoryStatusActive) {
966
        return &models.StoryGenerationEligibilityResponse{
967
            CanGenerate: false,
968
            Reason:      "story is not active",
969
        }, nil
970
    }
971

972
    // Check engagement-based generation if enabled and this is worker generation
973
    // Manual user generation should always be allowed regardless of engagement
974
19x
    if s.config.Story.EngagementBasedGeneration && generatorType == models.GeneratorTypeWorker {
975
3x
        // Get the user ID for this story to check engagement
976
3x
        userIDQuery := "SELECT user_id FROM stories WHERE id = $1"
977
3x
        var userID uint
978
3x
        err = s.db.QueryRowContext(ctx, userIDQuery, storyID).Scan(&userID)
979
3x
        if err != nil {
980
            return nil, contextutils.WrapErrorf(err, "failed to get user ID for story")
981
        }
982

983
        // Check if user has viewed the latest section
984
3x
        hasViewedLatest, err := s.HasUserViewedLatestSection(ctx, userID)
985
3x
        if err != nil {
986
            return nil, contextutils.WrapErrorf(err, "failed to check user engagement")
987
        }
988
3x
        if !hasViewedLatest {
989
1x
            return &models.StoryGenerationEligibilityResponse{
990
1x
                CanGenerate: false,
991
1x
                Reason:      "user has not viewed the latest section",
992
1x
            }, nil
993
1x
        }
994
    }
995

996
    // Check generation count for today by generator type
997
18x
    today := time.Now().Truncate(24 * time.Hour)
998
18x
    var sectionCount int
999
18x
    sectionQuery := `
1000
18x
        SELECT COUNT(*)
1001
18x
        FROM story_sections
1002
18x
        WHERE story_id = $1 AND generation_date = $2 AND generated_by = $3`
1003
18x

1004
18x
    err = s.db.QueryRowContext(ctx, sectionQuery, storyID, today, generatorType).Scan(&sectionCount)
1005
18x
    if err != nil {
1006
        return nil, contextutils.WrapErrorf(err, "failed to check existing sections today by generator type")
1007
    }
1008
18x
    span.SetAttributes(attribute.Int(fmt.Sprintf("section_count_%s", generatorType), sectionCount))
1009
18x
    span.SetAttributes(attribute.Int("max_worker_generations_per_day", s.config.Story.MaxWorkerGenerationsPerDay))
1010
18x
    span.SetAttributes(attribute.Int("max_user_generations_per_day", s.config.Story.MaxExtraGenerationsPerDay))
1011
18x

1012
18x
    // Check limits based on generator type
1013
18x
    switch generatorType {
1014
5x
    case models.GeneratorTypeWorker:
1015
5x
        // Worker can generate MaxWorkerGenerationsPerDay sections per day
1016
5x
        if sectionCount >= s.config.Story.MaxWorkerGenerationsPerDay {
1017
            return &models.StoryGenerationEligibilityResponse{
1018
                CanGenerate: false,
1019
                Reason:      fmt.Sprintf("worker has reached daily generation limit (%d)", s.config.Story.MaxWorkerGenerationsPerDay),
1020
            }, nil
1021
        }
1022
13x
    case models.GeneratorTypeUser:
1023
13x
        if sectionCount >= s.config.Story.MaxExtraGenerationsPerDay {
1024
5x
            return &models.StoryGenerationEligibilityResponse{
1025
5x
                CanGenerate: false,
1026
5x
                Reason:      fmt.Sprintf("user has reached daily generation limit (%d)", s.config.Story.MaxExtraGenerationsPerDay),
1027
5x
            }, nil
1028
5x
        }
1029
    default:
1030
        return &models.StoryGenerationEligibilityResponse{
1031
            CanGenerate: false,
1032
            Reason:      "invalid generator type",
1033
        }, nil
1034
    }
1035

1036
    // Allow generation if within limits
1037
13x
    return &models.StoryGenerationEligibilityResponse{
1038
13x
        CanGenerate: true,
1039
13x
    }, nil
1040
}
1041

1042
// UpdateLastGenerationTime sets the last section generation time for a story
1043
12x
func (s *StoryService) UpdateLastGenerationTime(ctx context.Context, storyID uint, generatorType models.GeneratorType) (err error) {
1044
12x
    ctx, span := observability.TraceFunction(ctx, "story_service", "update_last_generation_time",
1045
12x
        attribute.Int("story.id", int(storyID)),
1046
12x
        observability.AttributeGenerationType(generatorType),
1047
12x
    )
1048
12x
    defer observability.FinishSpan(span, &err)
1049
12x
    // Check if this is an extra generation (second generation today)
1050
12x
    query := `
1051
12x
        SELECT last_section_generated_at, extra_generations_today
1052
12x
        FROM stories
1053
12x
        WHERE id = $1`
1054
12x

1055
12x
    var lastGen *time.Time
1056
12x
    var extraGenerationsToday int
1057
12x

1058
12x
    err = s.db.QueryRowContext(ctx, query, storyID).Scan(&lastGen, &extraGenerationsToday)
1059
12x
    if err != nil {
1060
        return contextutils.WrapErrorf(err, "failed to get current generation info")
1061
    }
1062

1063
12x
    now := time.Now()
1064
12x

1065
12x
    // Check if we already generated today and update accordingly
1066
12x
    if lastGen != nil {
1067
8x
        lastGenTime := lastGen.Truncate(24 * time.Hour)
1068
8x
        today := now.Truncate(24 * time.Hour)
1069
8x
        if lastGenTime.Equal(today) {
1070
8x
            // Only increment counter for user generations
1071
8x
            if generatorType == models.GeneratorTypeUser {
1072
7x
                maxTotal := s.config.Story.MaxExtraGenerationsPerDay + 1
1073
7x
                if extraGenerationsToday < maxTotal {
1074
7x
                    updateQuery := "UPDATE stories SET extra_generations_today = extra_generations_today + 1, last_section_generated_at = $1, updated_at = NOW() WHERE id = $2"
1075
7x
                    _, err = s.db.ExecContext(ctx, updateQuery, now, storyID)
1076
7x
                    if err != nil {
1077
                        return contextutils.WrapErrorf(err, "failed to update generation time")
1078
                    }
1079
                } else {
1080
                    // Limit reached - just update timestamp
1081
                    updateQuery := "UPDATE stories SET last_section_generated_at = $1, updated_at = NOW() WHERE id = $2"
1082
                    _, err = s.db.ExecContext(ctx, updateQuery, now, storyID)
1083
                    if err != nil {
1084
                        return contextutils.WrapErrorf(err, "failed to update generation time")
1085
                    }
1086
                }
1087
1x
            } else {
1088
1x
                // Worker generation - just update timestamp
1089
1x
                updateQuery := "UPDATE stories SET last_section_generated_at = $1, updated_at = NOW() WHERE id = $2"
1090
1x
                _, err = s.db.ExecContext(ctx, updateQuery, now, storyID)
1091
1x
                if err != nil {
1092
                    return contextutils.WrapErrorf(err, "failed to update generation time")
1093
                }
1094
            }
1095
8x
            return nil
1096
        }
1097
    }
1098

1099
    // First generation today - only increment counter for user generations
1100
4x
    if generatorType == models.GeneratorTypeUser {
1101
2x
        updateQuery := "UPDATE stories SET extra_generations_today = extra_generations_today + 1, last_section_generated_at = $1, updated_at = NOW() WHERE id = $2"
1102
2x
        _, err = s.db.ExecContext(ctx, updateQuery, now, storyID)
1103
2x
        if err != nil {
1104
            return contextutils.WrapErrorf(err, "failed to update generation time for first generation")
1105
        }
1106
2x
    } else {
1107
2x
        // Worker generation - just update timestamp
1108
2x
        updateQuery := "UPDATE stories SET last_section_generated_at = $1, updated_at = NOW() WHERE id = $2"
1109
2x
        _, err = s.db.ExecContext(ctx, updateQuery, now, storyID)
1110
2x
        if err != nil {
1111
            return contextutils.WrapErrorf(err, "failed to update generation time for first generation")
1112
        }
1113
    }
1114

1115
4x
    return nil
1116
}
1117

1118
// RecordStorySectionView records that a user has viewed a story section
1119
6x
func (s *StoryService) RecordStorySectionView(ctx context.Context, userID, sectionID uint) (err error) {
1120
6x
    ctx, span := observability.TraceFunction(ctx, "story_service", "record_section_view",
1121
6x
        observability.AttributeUserID(int(userID)),
1122
6x
        attribute.Int("section.id", int(sectionID)),
1123
6x
    )
1124
6x
    defer observability.FinishSpan(span, &err)
1125
6x

1126
6x
    // Use UPSERT to either insert a new view or update the viewed_at timestamp if the view already exists
1127
6x
    query := `
1128
6x
        INSERT INTO story_section_views (user_id, section_id, viewed_at, created_at)
1129
6x
        VALUES ($1, $2, NOW(), NOW())
1130
6x
        ON CONFLICT (user_id, section_id)
1131
6x
        DO UPDATE SET viewed_at = NOW()`
1132
6x

1133
6x
    _, err = s.db.ExecContext(ctx, query, userID, sectionID)
1134
6x
    if err != nil {
1135
        return contextutils.WrapErrorf(err, "failed to record story section view")
1136
    }
1137

1138
6x
    return nil
1139
}
1140

1141
// HasUserViewedLatestSection checks if a user has viewed the latest section of their story
1142
12x
func (s *StoryService) HasUserViewedLatestSection(ctx context.Context, userID uint) (bool, error) {
1143
12x
    ctx, span := observability.TraceFunction(ctx, "story_service", "has_user_viewed_latest_section",
1144
12x
        observability.AttributeUserID(int(userID)),
1145
12x
    )
1146
12x
    defer observability.FinishSpan(span, nil)
1147
12x

1148
12x
    // Get the user's current active story
1149
12x
    story, err := s.GetCurrentStory(ctx, userID)
1150
12x
    if err != nil {
1151
        return false, contextutils.WrapErrorf(err, "failed to get current story")
1152
    }
1153
12x
    if story == nil {
1154
1x
        // No current story - can't generate anything
1155
1x
        return false, nil
1156
1x
    }
1157
11x
    if len(story.Sections) == 0 {
1158
3x
        // Story exists but has no sections yet - allow first section generation
1159
3x
        return true, nil
1160
3x
    }
1161

1162
    // Get the latest section (highest section number)
1163
8x
    latestSection := story.Sections[len(story.Sections)-1]
1164
8x

1165
8x
    // Check if user has viewed this section
1166
8x
    query := `
1167
8x
        SELECT EXISTS(
1168
8x
            SELECT 1 FROM story_section_views
1169
8x
            WHERE user_id = $1 AND section_id = $2
1170
8x
        )`
1171
8x

1172
8x
    var hasViewed bool
1173
8x
    err = s.db.QueryRowContext(ctx, query, userID, latestSection.ID).Scan(&hasViewed)
1174
8x
    if err != nil {
1175
        return false, contextutils.WrapErrorf(err, "failed to check if user viewed latest section")
1176
    }
1177

1178
8x
    return hasViewed, nil
1179
}
1180

1181
// Helper methods
1182

1183
// getUserByID retrieves a user by their ID
1184
22x
func (s *StoryService) getUserByID(ctx context.Context, userID uint) (*models.User, error) {
1185
22x
    query := "SELECT id, username, email, preferred_language, current_level, ai_provider, ai_model, ai_api_key, created_at, updated_at FROM users WHERE id = $1"
1186
22x

1187
22x
    var user models.User
1188
22x
    err := s.db.QueryRowContext(ctx, query, userID).Scan(
1189
22x
        &user.ID, &user.Username, &user.Email, &user.PreferredLanguage,
1190
22x
        &user.CurrentLevel, &user.AIProvider, &user.AIModel, &user.AIAPIKey,
1191
22x
        &user.CreatedAt, &user.UpdatedAt,
1192
22x
    )
1193
22x
    if err != nil {
1194
1x
        if errors.Is(err, sql.ErrNoRows) {
1195
1x
            return nil, nil // User not found
1196
1x
        }
1197
        return nil, contextutils.WrapErrorf(err, "failed to get user")
1198
    }
1199

1200
21x
    return &user, nil
1201
}
1202

1203
// getArchivedStoryCount counts archived stories for a user
1204
19x
func (s *StoryService) getArchivedStoryCount(ctx context.Context, userID uint) (int, error) {
1205
19x
    query := "SELECT COUNT(*) FROM stories WHERE user_id = $1 AND status = $2"
1206
19x
    var count int
1207
19x
    err := s.db.QueryRowContext(ctx, query, userID, models.StoryStatusArchived).Scan(&count)
1208
19x
    if err != nil {
1209
        return 0, err
1210
    }
1211

1212
19x
    return count, nil
1213
}
1214

1215
// getUserCurrentLevel retrieves the user's current language level
1216
19x
func (s *StoryService) getUserCurrentLevel(ctx context.Context, userID uint) (string, error) {
1217
19x
    query := "SELECT current_level FROM users WHERE id = $1"
1218
19x
    var level sql.NullString
1219
19x
    err := s.db.QueryRowContext(ctx, query, userID).Scan(&level)
1220
19x
    if err != nil {
1221
        return "", contextutils.WrapErrorf(err, "failed to get user")
1222
    }
1223

1224
19x
    if !level.Valid {
1225
        return "", contextutils.ErrorWithContextf("user has no current level set")
1226
    }
1227

1228
19x
    return level.String, nil
1229
}
1230

1231
// validateStoryOwnership verifies that a user owns a story
1232
2x
func (s *StoryService) validateStoryOwnership(ctx context.Context, storyID, userID uint) error {
1233
2x
    query := "SELECT COUNT(*) FROM stories WHERE id = $1 AND user_id = $2"
1234
2x
    var count int
1235
2x
    err := s.db.QueryRowContext(ctx, query, storyID, userID).Scan(&count)
1236
2x
    if err != nil {
1237
        return contextutils.WrapErrorf(err, "failed to validate story ownership")
1238
    }
1239

1240
2x
    if count == 0 {
1241
        return contextutils.ErrorWithContextf("story not found or access denied")
1242
    }
1243

1244
2x
    return nil
1245
}
1246

1247
// GetSectionLengthTarget returns the target word count for a story section
1248
func (s *StoryService) GetSectionLengthTarget(level string, lengthPref *models.SectionLength) int {
1249
    return models.GetSectionLengthTarget(level, lengthPref)
1250
}
1251

1252
// GetSectionLengthTargetWithLanguage returns the target word count with language-specific overrides
1253
func (s *StoryService) GetSectionLengthTargetWithLanguage(language, level string, lengthPref *models.SectionLength) int {
1254
    // Check for language-specific overrides in config
1255
    if languageOverrides, exists := s.config.Story.SectionLengths.Overrides[language]; exists {
1256
        if levelTargets, exists := languageOverrides[level]; exists {
1257
            // Use the override if it exists for this level
1258
            if lengthPref != nil {
1259
                if target, exists := levelTargets[string(*lengthPref)]; exists {
1260
                    return target
1261
                }
1262
            }
1263
            // Default to medium if no specific length preference
1264
            if target, exists := levelTargets["medium"]; exists {
1265
                return target
1266
            }
1267
        }
1268
    }
1269

1270
    // Fall back to the default implementation
1271
    return models.GetSectionLengthTarget(level, lengthPref)
1272
}
1273

1274
// SanitizeInput sanitizes user input for safe use in AI prompts
1275
func (s *StoryService) SanitizeInput(input string) string {
1276
    return models.SanitizeInput(input)
1277
}
1278

1279
// Database helper methods using sql.DB
1280

1281
// createStory inserts a new story into the database
1282
19x
func (s *StoryService) createStory(ctx context.Context, story *models.Story) error {
1283
19x
    query := `
1284
19x
        INSERT INTO stories (
1285
19x
            user_id, title, language, subject, author_style, time_period, genre, tone,
1286
19x
            character_names, custom_instructions, section_length_override, status,
1287
19x
            created_at, updated_at
1288
19x
        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
1289
19x
        RETURNING id`
1290
19x

1291
19x
    err := s.db.QueryRowContext(ctx, query,
1292
19x
        story.UserID, story.Title, story.Language, story.Subject, story.AuthorStyle,
1293
19x
        story.TimePeriod, story.Genre, story.Tone, story.CharacterNames,
1294
19x
        story.CustomInstructions, story.SectionLengthOverride, story.Status,
1295
19x
        story.CreatedAt, story.UpdatedAt,
1296
19x
    ).Scan(&story.ID)
1297
19x

1298
19x
    return err
1299
19x
}
1300

1301
// Admin-only methods (no ownership checks)
1302

1303
// GetStoriesPaginated returns stories with optional filters for admin views
1304
func (s *StoryService) GetStoriesPaginated(ctx context.Context, page, pageSize int, search, language, status string, userID *uint) ([]models.Story, int, error) {
1305
    if page <= 0 {
1306
        page = 1
1307
    }
1308
    if pageSize <= 0 || pageSize > 100 {
1309
        pageSize = 20
1310
    }
1311

1312
    // Build WHERE clauses dynamically
1313
    where := []string{"1=1"}
1314
    args := []interface{}{}
1315
    argIdx := 1
1316
    if search != "" {
1317
        where = append(where, fmt.Sprintf("(LOWER(title) LIKE $%d)", argIdx))
1318
        args = append(args, "%"+strings.ToLower(search)+"%")
1319
        argIdx++
1320
    }
1321
    if language != "" {
1322
        where = append(where, fmt.Sprintf("language = $%d", argIdx))
1323
        args = append(args, language)
1324
        argIdx++
1325
    }
1326
    if status != "" {
1327
        where = append(where, fmt.Sprintf("status = $%d", argIdx))
1328
        args = append(args, status)
1329
        argIdx++
1330
    }
1331
    if userID != nil {
1332
        where = append(where, fmt.Sprintf("user_id = $%d", argIdx))
1333
        args = append(args, *userID)
1334
        argIdx++
1335
    }
1336

1337
    whereClause := strings.Join(where, " AND ")
1338

1339
    // Count total
1340
    countQuery := "SELECT COUNT(*) FROM stories WHERE " + whereClause
1341
    var total int
1342
    if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
1343
        return nil, 0, contextutils.WrapErrorf(err, "failed to count stories")
1344
    }
1345

1346
    // Fetch rows
1347
    offset := (page - 1) * pageSize
1348
    listQuery := `
1349
        SELECT id, user_id, title, language, subject, author_style, time_period, genre, tone,
1350
               character_names, custom_instructions, section_length_override, status,
1351
               last_section_generated_at, created_at, updated_at
1352
        FROM stories
1353
        WHERE ` + whereClause + `
1354
        ORDER BY created_at DESC
1355
        LIMIT $` + fmt.Sprint(argIdx) + ` OFFSET $` + fmt.Sprint(argIdx+1)
1356

1357
    args = append(args, pageSize, offset)
1358

1359
    rows, err := s.db.QueryContext(ctx, listQuery, args...)
1360
    if err != nil {
1361
        return nil, 0, contextutils.WrapErrorf(err, "failed to query stories")
1362
    }
1363
    defer func() { _ = rows.Close() }()
1364

1365
    stories := []models.Story{}
1366
    for rows.Next() {
1367
        var story models.Story
1368
        if err := rows.Scan(
1369
            &story.ID, &story.UserID, &story.Title, &story.Language, &story.Subject,
1370
            &story.AuthorStyle, &story.TimePeriod, &story.Genre, &story.Tone,
1371
            &story.CharacterNames, &story.CustomInstructions, &story.SectionLengthOverride,
1372
            &story.Status, &story.LastSectionGeneratedAt,
1373
            &story.CreatedAt, &story.UpdatedAt,
1374
        ); err != nil {
1375
            return nil, 0, contextutils.WrapErrorf(err, "failed to scan story")
1376
        }
1377
        stories = append(stories, story)
1378
    }
1379

1380
    return stories, total, rows.Err()
1381
}
1382

1383
// GetStoryAdmin returns story with sections for admin (no ownership checks)
1384
func (s *StoryService) GetStoryAdmin(ctx context.Context, storyID uint) (*models.StoryWithSections, error) {
1385
    query := `
1386
        SELECT id, user_id, title, language, subject, author_style, time_period, genre, tone,
1387
               character_names, custom_instructions, section_length_override, status,
1388
               last_section_generated_at, created_at, updated_at
1389
        FROM stories
1390
        WHERE id = $1`
1391

1392
    var story models.Story
1393
    if err := s.db.QueryRowContext(ctx, query, storyID).Scan(
1394
        &story.ID, &story.UserID, &story.Title, &story.Language, &story.Subject,
1395
        &story.AuthorStyle, &story.TimePeriod, &story.Genre, &story.Tone,
1396
        &story.CharacterNames, &story.CustomInstructions, &story.SectionLengthOverride,
1397
        &story.Status, &story.LastSectionGeneratedAt,
1398
        &story.CreatedAt, &story.UpdatedAt,
1399
    ); err != nil {
1400
        if errors.Is(err, sql.ErrNoRows) {
1401
            return nil, contextutils.ErrorWithContextf("story not found")
1402
        }
1403
        return nil, contextutils.WrapErrorf(err, "failed to get story")
1404
    }
1405

1406
    sections, err := s.GetStorySections(ctx, story.ID)
1407
    if err != nil {
1408
        return nil, contextutils.WrapErrorf(err, "failed to load story sections")
1409
    }
1410

1411
    return &models.StoryWithSections{Story: story, Sections: sections}, nil
1412
}
1413

1414
// GetSectionAdmin returns section with questions for admin (no ownership checks)
1415
func (s *StoryService) GetSectionAdmin(ctx context.Context, sectionID uint) (*models.StorySectionWithQuestions, error) {
1416
    query := `
1417
        SELECT id, story_id, section_number, content, language_level, word_count,
1418
               generated_by, generated_at, generation_date
1419
        FROM story_sections
1420
        WHERE id = $1`
1421

1422
    var section models.StorySection
1423
    if err := s.db.QueryRowContext(ctx, query, sectionID).Scan(
1424
        &section.ID, &section.StoryID, &section.SectionNumber, &section.Content,
1425
        &section.LanguageLevel, &section.WordCount, &section.GeneratedBy, &section.GeneratedAt, &section.GenerationDate,
1426
    ); err != nil {
1427
        if errors.Is(err, sql.ErrNoRows) {
1428
            return nil, contextutils.ErrorWithContextf("section not found")
1429
        }
1430
        return nil, contextutils.WrapErrorf(err, "failed to get section")
1431
    }
1432

1433
    questions, err := s.GetSectionQuestions(ctx, section.ID)
1434
    if err != nil {
1435
        return nil, contextutils.WrapErrorf(err, "failed to load section questions")
1436
    }
1437

1438
    return &models.StorySectionWithQuestions{StorySection: section, Questions: questions}, nil
1439
}
1440

1441
// createSection inserts a new section into the database
1442
22x
func (s *StoryService) createSection(ctx context.Context, section *models.StorySection) error {
1443
22x
    query := `
1444
22x
        INSERT INTO story_sections (
1445
22x
            story_id, section_number, content, language_level, word_count, generated_by,
1446
22x
            generated_at, generation_date
1447
22x
        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
1448
22x
        RETURNING id`
1449
22x

1450
22x
    err := s.db.QueryRowContext(ctx, query,
1451
22x
        section.StoryID, section.SectionNumber, section.Content, section.LanguageLevel,
1452
22x
        section.WordCount, section.GeneratedBy, section.GeneratedAt, section.GenerationDate,
1453
22x
    ).Scan(&section.ID)
1454
22x

1455
22x
    return err
1456
22x
}
1457

1458
// createSectionInTx creates a section within an existing database transaction
1459
func (s *StoryService) createSectionInTx(ctx context.Context, tx *sql.Tx, section *models.StorySection) error {
1460
    query := `
1461
        INSERT INTO story_sections (
1462
            story_id, section_number, content, language_level, word_count, generated_by,
1463
            generated_at, generation_date
1464
        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
1465
        RETURNING id`
1466

1467
    err := tx.QueryRowContext(ctx, query,
1468
        section.StoryID, section.SectionNumber, section.Content, section.LanguageLevel,
1469
        section.WordCount, section.GeneratedBy, section.GeneratedAt, section.GenerationDate,
1470
    ).Scan(&section.ID)
1471

1472
    return err
1473
}
1474

1475
// GenerateStorySection generates a new section for a story using AI
1476
func (s *StoryService) GenerateStorySection(ctx context.Context, storyID, userID uint, aiService AIServiceInterface, userAIConfig *models.UserAIConfig, generatorType models.GeneratorType) (*models.StorySectionWithQuestions, error) {
1477
    ctx, span := observability.TraceFunction(ctx, "story_service", "generate_section",
1478
        attribute.Int("story.id", int(storyID)),
1479
        observability.AttributeUserID(int(userID)),
1480
        observability.AttributeGenerationType(generatorType),
1481
        attribute.String("model", userAIConfig.Model),
1482
        attribute.String("provider", userAIConfig.Provider),
1483
        attribute.String("username", userAIConfig.Username),
1484
    )
1485
    defer observability.FinishSpan(span, nil)
1486

1487
    // Get the story to verify ownership and get details
1488
    story, err := s.GetStory(ctx, storyID, userID)
1489
    if err != nil {
1490
        return nil, contextutils.WrapErrorf(err, "failed to get story for generation")
1491
    }
1492
    span.SetAttributes(attribute.String("story.title", story.Title))
1493
    span.SetAttributes(attribute.String("story.language", story.Language))
1494
    span.SetAttributes(attribute.String("story.section_length_override", story.GetSectionLengthOverride()))
1495
    span.SetAttributes(attribute.String("story.subject", stringPtrToString(story.Subject)))
1496
    span.SetAttributes(attribute.String("story.author_style", stringPtrToString(story.AuthorStyle)))
1497
    span.SetAttributes(attribute.String("story.time_period", stringPtrToString(story.TimePeriod)))
1498
    span.SetAttributes(attribute.String("story.genre", stringPtrToString(story.Genre)))
1499
    span.SetAttributes(attribute.String("story.tone", stringPtrToString(story.Tone)))
1500
    span.SetAttributes(attribute.String("story.character_names", stringPtrToString(story.CharacterNames)))
1501
    span.SetAttributes(attribute.String("story.custom_instructions", stringPtrToString(story.CustomInstructions)))
1502

1503
    // Check if generation is allowed today by this generator type
1504
    eligibility, err := s.canGenerateSection(ctx, storyID, generatorType)
1505
    if err != nil {
1506
        return nil, contextutils.WrapErrorf(err, "failed to check generation eligibility")
1507
    }
1508
    if !eligibility.CanGenerate {
1509
        return nil, contextutils.WrapError(contextutils.ErrGenerationLimitReached, eligibility.Reason)
1510
    }
1511

1512
    // Get user for AI configuration and language preferences
1513
    user, err := s.getUserByID(ctx, userID)
1514
    if err != nil {
1515
        return nil, contextutils.WrapErrorf(err, "failed to get user")
1516
    }
1517
    if user == nil {
1518
        return nil, contextutils.ErrorWithContextf("user not found")
1519
    }
1520

1521
    // Get all previous sections for context
1522
    previousSections, err := s.GetAllSectionsText(ctx, storyID)
1523
    if err != nil {
1524
        return nil, contextutils.WrapErrorf(err, "failed to get previous sections")
1525
    }
1526

1527
    // Get the user's current language level (handle sql.NullString)
1528
    if !user.CurrentLevel.Valid {
1529
        return nil, contextutils.ErrorWithContextf("user level not found")
1530
    }
1531
    span.SetAttributes(attribute.String("story.level", user.CurrentLevel.String))
1532

1533
    // Determine target length for this user's level
1534
    targetWords := s.GetSectionLengthTarget(user.CurrentLevel.String, story.SectionLengthOverride)
1535

1536
    // Build the generation request
1537
    genReq := &models.StoryGenerationRequest{
1538
        UserID:             userID,
1539
        StoryID:            storyID,
1540
        Language:           story.Language,
1541
        Level:              user.CurrentLevel.String,
1542
        Title:              story.Title,
1543
        Subject:            story.Subject,
1544
        AuthorStyle:        story.AuthorStyle,
1545
        TimePeriod:         story.TimePeriod,
1546
        Genre:              story.Genre,
1547
        Tone:               story.Tone,
1548
        CharacterNames:     story.CharacterNames,
1549
        CustomInstructions: story.CustomInstructions,
1550
        SectionLength:      models.SectionLengthMedium, // Use medium as default
1551
        PreviousSections:   previousSections,
1552
        IsFirstSection:     len(story.Sections) == 0,
1553
        TargetWords:        targetWords,
1554
        TargetSentences:    targetWords / 15, // Rough estimate
1555
    }
1556

1557
    // Generate the story section using AI
1558
    sectionContent, err := aiService.GenerateStorySection(ctx, userAIConfig, genReq)
1559
    if err != nil {
1560
        // Check if this is a context cancellation error
1561
        if ctx.Err() == context.DeadlineExceeded {
1562
            s.logger.Error(ctx, "Story section generation timed out", err, map[string]interface{}{
1563
                "story_id": storyID,
1564
                "user_id":  userID,
1565
            })
1566
            return nil, contextutils.WrapErrorf(contextutils.ErrTimeout, "story generation timed out: %w", err)
1567
        }
1568
        return nil, contextutils.WrapErrorf(err, "failed to generate story section")
1569
    }
1570

1571
    // Count words in the generated content
1572
    wordCount := len(strings.Fields(sectionContent))
1573

1574
    // Start a database transaction to ensure atomicity of section and questions creation
1575
    tx, err := s.db.BeginTx(ctx, nil)
1576
    if err != nil {
1577
        return nil, contextutils.WrapErrorf(err, "failed to begin transaction")
1578
    }
1579
    span.AddEvent("transaction_began")
1580

1581
    var committed bool
1582
    defer func() {
1583
        if !committed {
1584
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
1585
                s.logger.Warn(ctx, "Failed to rollback transaction",
1586
                    map[string]interface{}{
1587
                        "story_id": storyID,
1588
                        "user_id":  userID,
1589
                        "error":    rollbackErr.Error(),
1590
                    })
1591
            }
1592
            span.AddEvent("transaction_rolled_back")
1593
        }
1594
    }()
1595

1596
    // Create the section within the transaction
1597
    section := &models.StorySection{
1598
        StoryID:        storyID,
1599
        SectionNumber:  0, // Will be set by createSectionInTx
1600
        Content:        sectionContent,
1601
        LanguageLevel:  user.CurrentLevel.String,
1602
        WordCount:      wordCount,
1603
        GeneratedBy:    generatorType,
1604
        GeneratedAt:    time.Now(),
1605
        GenerationDate: time.Now().Truncate(24 * time.Hour),
1606
    }
1607

1608
    // Get the next section number within the transaction
1609
    var maxSectionNumber int
1610
    query := "SELECT COALESCE(MAX(section_number), 0) FROM story_sections WHERE story_id = $1"
1611
    err = tx.QueryRowContext(ctx, query, storyID).Scan(&maxSectionNumber)
1612
    if err != nil {
1613
        return nil, contextutils.WrapErrorf(err, "failed to get max section number")
1614
    }
1615
    section.SectionNumber = maxSectionNumber + 1
1616
    span.SetAttributes(attribute.Int("section.number", section.SectionNumber))
1617

1618
    // Create the section in the database within the transaction
1619
    if err := s.createSectionInTx(ctx, tx, section); err != nil {
1620
        return nil, contextutils.WrapErrorf(err, "failed to create story section")
1621
    }
1622
    span.AddEvent("section_created")
1623

1624
    // Generate comprehension questions for the section
1625
    questionsReq := &models.StoryQuestionsRequest{
1626
        UserID:        userID,
1627
        SectionID:     section.ID,
1628
        Language:      story.Language,
1629
        Level:         user.CurrentLevel.String,
1630
        SectionText:   sectionContent,
1631
        QuestionCount: s.config.Story.QuestionsPerSection,
1632
    }
1633

1634
    var questions []*models.StorySectionQuestionData
1635
    questions, err = aiService.GenerateStoryQuestions(ctx, userAIConfig, questionsReq)
1636
    if err != nil {
1637
        // Check if this is a context cancellation error
1638
        if ctx.Err() == context.DeadlineExceeded {
1639
            s.logger.Warn(ctx, "Story questions generation timed out, continuing without questions",
1640
                map[string]interface{}{
1641
                    "section_id": section.ID,
1642
                    "story_id":   storyID,
1643
                    "user_id":    userID,
1644
                    "error":      err.Error(),
1645
                })
1646
        } else {
1647
            s.logger.Warn(ctx, "Failed to generate questions for story section",
1648
                map[string]interface{}{
1649
                    "section_id": section.ID,
1650
                    "story_id":   storyID,
1651
                    "user_id":    userID,
1652
                    "error":      err.Error(),
1653
                })
1654
            span.AddEvent("failed_to_generate_questions")
1655
        }
1656
        // Continue anyway - questions are nice to have but not critical
1657
    } else {
1658
        // Convert to database model slice (dereference pointers)
1659
        dbQuestions := make([]models.StorySectionQuestionData, len(questions))
1660
        for i, q := range questions {
1661
            dbQuestions[i] = *q
1662
        }
1663

1664
        // Save the questions to the database within the same transaction
1665
        if err := s.createSectionQuestionsInTx(ctx, tx, section.ID, dbQuestions); err != nil {
1666
            s.logger.Warn(ctx, "Failed to save story questions",
1667
                map[string]interface{}{
1668
                    "section_id": section.ID,
1669
                    "story_id":   storyID,
1670
                    "user_id":    userID,
1671
                    "error":      err.Error(),
1672
                })
1673
            span.AddEvent("failed_to_save_questions")
1674
        }
1675
        span.AddEvent("questions_saved")
1676
    }
1677

1678
    // Commit the transaction
1679
    if err := tx.Commit(); err != nil {
1680
        span.AddEvent("failed_to_commit_transaction")
1681
        return nil, contextutils.WrapErrorf(err, "failed to commit transaction")
1682
    }
1683
    committed = true
1684
    span.AddEvent("transaction_committed")
1685

1686
    // Update the story's last generation time
1687
    if err := s.UpdateLastGenerationTime(ctx, storyID, generatorType); err != nil {
1688
        s.logger.Warn(ctx, "Failed to update story generation time",
1689
            map[string]interface{}{
1690
                "story_id": storyID,
1691
                "user_id":  userID,
1692
                "error":    err.Error(),
1693
            })
1694
    }
1695

1696
    s.logger.Info(ctx, "Story section generated successfully",
1697
        map[string]interface{}{
1698
            "story_id":       storyID,
1699
            "section_id":     section.ID,
1700
            "section_number": section.SectionNumber,
1701
            "user_id":        userID,
1702
            "word_count":     wordCount,
1703
            "question_count": len(questions),
1704
        })
1705

1706
    // Load questions for the section
1707
    sectionQuestions, err := s.GetSectionQuestions(ctx, section.ID)
1708
    if err != nil {
1709
        s.logger.Warn(ctx, "Failed to load section questions after generation",
1710
            map[string]interface{}{
1711
                "section_id": section.ID,
1712
                "story_id":   storyID,
1713
                "user_id":    userID,
1714
                "error":      err.Error(),
1715
            })
1716
        // Return section without questions rather than failing
1717
        sectionQuestions = []models.StorySectionQuestion{}
1718
    }
1719

1720
    return &models.StorySectionWithQuestions{
1721
        StorySection: *section,
1722
        Questions:    sectionQuestions,
1723
    }, nil
1724
}
1725


			
quizapp internal services worker_service.go
36.5%
Statements
23/63
1
// Package services provides business logic services for the quiz application.
2
package services
3

4
import (
5
    "context"
6
    "database/sql"
7
    "fmt"
8
    "time"
9

10
    "quizapp/internal/config"
11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    contextutils "quizapp/internal/utils"
14

15
    "go.opentelemetry.io/otel"
16
    "go.opentelemetry.io/otel/attribute"
17
    "go.opentelemetry.io/otel/trace"
18
)
19

20
// TestEmailService implements the Mailer interface for testing purposes
21
// It doesn't actually send emails but logs the operations and records them in the database
22
type TestEmailService struct {
23
    cfg    *config.Config
24
    logger *observability.Logger
25
    db     *sql.DB
26
}
27

28
// NewTestEmailService creates a new TestEmailService instance
29
6x
func NewTestEmailService(cfg *config.Config, logger *observability.Logger) *TestEmailService {
30
6x
    return &TestEmailService{
31
6x
        cfg:    cfg,
32
6x
        logger: logger,
33
6x
    }
34
6x
}
35

36
// NewTestEmailServiceWithDB creates a new TestEmailService instance with database connection
37
1x
func NewTestEmailServiceWithDB(cfg *config.Config, logger *observability.Logger, db *sql.DB) *TestEmailService {
38
1x
    return &TestEmailService{
39
1x
        cfg:    cfg,
40
1x
        logger: logger,
41
1x
        db:     db,
42
1x
    }
43
1x
}
44

45
// SendDailyReminder sends a daily reminder email to a user (test mode - just logs)
46
2x
func (e *TestEmailService) SendDailyReminder(ctx context.Context, user *models.User) error {
47
2x
    ctx, span := otel.Tracer("test-email-service").Start(ctx, "SendDailyReminder",
48
2x
        trace.WithAttributes(
49
2x
            attribute.Int("user.id", user.ID),
50
2x
            attribute.String("user.email", user.Email.String),
51
2x
        ),
52
2x
    )
53
2x
    defer span.End()
54
2x

55
2x
    if !user.Email.Valid || user.Email.String == "" {
56
        e.logger.Warn(ctx, "User has no email address, skipping daily reminder", map[string]interface{}{
57
            "user_id": user.ID,
58
        })
59
        return nil
60
    }
61

62
    // Generate email data (same as real service) - not used in test mode but kept for consistency
63
2x
    _ = map[string]interface{}{
64
2x
        "Username":       user.Username,
65
2x
        "QuizAppURL":     e.cfg.Server.AppBaseURL,
66
2x
        "CurrentDate":    time.Now().Format("January 2, 2006"),
67
2x
        "DailyGoal":      10,
68
2x
        "StreakDays":     5,
69
2x
        "TotalQuestions": 150,
70
2x
        "Level":          "B1",
71
2x
        "Language":       "Italian",
72
2x
    }
73
2x

74
2x
    // Log the email operation instead of sending. Use the same subject as the
75
2x
    // real service to avoid confusion, but do NOT record a second entry in the
76
2x
    // database here â recording is handled by caller to ensure a single source
77
2x
    // of truth for sent notifications.
78
2x
    e.logger.Info(ctx, "TEST MODE: Would send daily reminder email", map[string]interface{}{
79
2x
        "user_id":   user.ID,
80
2x
        "email":     user.Email.String,
81
2x
        "template":  "daily_reminder",
82
2x
        "subject":   "Time for your daily quiz! ð",
83
2x
        "test_mode": true,
84
2x
    })
85
2x

86
2x
    return nil
87
}
88

89
// SendWordOfTheDayEmail logs sending a word of the day email (test mode) and records it if a DB is available
90
func (e *TestEmailService) SendWordOfTheDayEmail(ctx context.Context, userID int, date time.Time, wordOfTheDay *models.WordOfTheDayDisplay) error {
91
    ctx, span := otel.Tracer("test-email-service").Start(ctx, "SendWordOfTheDayEmail",
92
        trace.WithAttributes(
93
            attribute.Int("user.id", userID),
94
            attribute.String("date", date.Format("2006-01-02")),
95
        ),
96
    )
97
    defer span.End()
98

99
    if wordOfTheDay == nil {
100
        err := contextutils.ErrorWithContextf("word of the day data is nil")
101
        span.RecordError(err)
102
        return contextutils.ErrorWithContextf("word of the day data is nil")
103
    }
104

105
    span.SetAttributes(attribute.String("word", wordOfTheDay.Word))
106

107
    e.logger.Info(ctx, "TEST MODE: Would send word of the day email", map[string]interface{}{
108
        "user_id":   userID,
109
        "word":      wordOfTheDay.Word,
110
        "date":      date.Format("2006-01-02"),
111
        "template":  "word_of_the_day",
112
        "test_mode": true,
113
    })
114

115
    if e.db != nil {
116
        subject := fmt.Sprintf("Word of the Day: %s - %s", wordOfTheDay.Word, date.Format("January 2, 2006"))
117
        if err := e.RecordSentNotification(ctx, userID, "word_of_the_day", subject, "word_of_the_day", "sent", ""); err != nil {
118
            return contextutils.WrapError(err, "failed to record word of the day notification in test mode")
119
        }
120
    }
121

122
    return nil
123
}
124

125
// SendEmail sends a generic email with the given parameters (test mode - just logs)
126
1x
func (e *TestEmailService) SendEmail(ctx context.Context, to, subject, templateName string, data map[string]interface{}) error {
127
1x
    ctx, span := otel.Tracer("test-email-service").Start(ctx, "SendEmail",
128
1x
        trace.WithAttributes(
129
1x
            attribute.String("email.to", to),
130
1x
            attribute.String("email.subject", subject),
131
1x
            attribute.String("email.template", templateName),
132
1x
        ),
133
1x
    )
134
1x
    defer span.End()
135
1x

136
1x
    // Log the email operation instead of sending
137
1x
    e.logger.Info(ctx, "TEST MODE: Would send email", map[string]interface{}{
138
1x
        "to":        to,
139
1x
        "subject":   subject,
140
1x
        "template":  templateName,
141
1x
        "test_mode": true,
142
1x
        "data_keys": getMapKeys(data),
143
1x
    })
144
1x

145
1x
    // Record the notification in the database if we have a DB connection
146
1x
    if e.db != nil {
147
        // For test emails, we don't have a user ID, so we'll use 0
148
        err := e.RecordSentNotification(ctx, 0, "test_email", subject, templateName, "sent", "")
149
        if err != nil {
150
            e.logger.Error(ctx, "Failed to record test notification", err, map[string]interface{}{
151
                "to":       to,
152
                "template": templateName,
153
            })
154
        }
155
    }
156

157
1x
    return nil
158
}
159

160
// HasSentWordOfTheDayEmail determines if a word-of-the-day email has already been sent for the provided date (test mode)
161
func (e *TestEmailService) HasSentWordOfTheDayEmail(ctx context.Context, userID int, date time.Time) (bool, error) {
162
    ctx, span := otel.Tracer("test-email-service").Start(ctx, "HasSentWordOfTheDayEmail",
163
        trace.WithAttributes(
164
            attribute.Int("user.id", userID),
165
            attribute.String("date", date.Format("2006-01-02")),
166
        ),
167
    )
168
    defer span.End()
169

170
    if e.db == nil {
171
        // Without a database we cannot track sent notifications; act as if none was sent
172
        e.logger.Warn(ctx, "No database connection available for querying word-of-day history", map[string]interface{}{
173
            "user_id": userID,
174
            "date":    date.Format("2006-01-02"),
175
        })
176
        return false, nil
177
    }
178

179
    start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
180
    end := start.Add(24 * time.Hour)
181

182
    const query = `
183
        SELECT EXISTS(
184
            SELECT 1
185
            FROM sent_notifications
186
            WHERE user_id = $1
187
              AND notification_type = 'word_of_the_day'
188
              AND status = 'sent'
189
              AND sent_at >= $2
190
              AND sent_at < $3
191
        )
192
    `
193

194
    var exists bool
195
    if err := e.db.QueryRowContext(ctx, query, userID, start.UTC(), end.UTC()).Scan(&exists); err != nil {
196
        span.RecordError(err)
197
        return false, contextutils.WrapError(err, "failed to check word of the day notification history")
198
    }
199

200
    span.SetAttributes(attribute.Bool("word_of_day.already_sent", exists))
201

202
    return exists, nil
203
}
204

205
// RecordSentNotification records a sent notification in the database
206
1x
func (e *TestEmailService) RecordSentNotification(ctx context.Context, userID int, notificationType, subject, templateName, status, errorMessage string) error {
207
1x
    ctx, span := otel.Tracer("test-email-service").Start(ctx, "RecordSentNotification",
208
1x
        trace.WithAttributes(
209
1x
            attribute.Int("user.id", userID),
210
1x
            attribute.String("notification.type", notificationType),
211
1x
            attribute.String("notification.status", status),
212
1x
        ),
213
1x
    )
214
1x
    defer span.End()
215
1x

216
1x
    if e.db == nil {
217
1x
        e.logger.Warn(ctx, "No database connection available for recording notification", map[string]interface{}{
218
1x
            "user_id":           userID,
219
1x
            "notification_type": notificationType,
220
1x
        })
221
1x
        return nil
222
1x
    }
223

224
    query := `
225
        INSERT INTO sent_notifications (user_id, notification_type, subject, template_name, sent_at, status, error_message)
226
        VALUES ($1, $2, $3, $4, $5, $6, $7)
227
    `
228

229
    _, err := e.db.ExecContext(ctx, query, userID, notificationType, subject, templateName, time.Now(), status, errorMessage)
230
    if err != nil {
231
        span.RecordError(err)
232
        e.logger.Error(ctx, "Failed to record sent notification", err, map[string]interface{}{
233
            "user_id":           userID,
234
            "notification_type": notificationType,
235
            "status":            status,
236
        })
237
        return contextutils.WrapError(err, "failed to record sent notification")
238
    }
239

240
    e.logger.Info(ctx, "Recorded sent notification", map[string]interface{}{
241
        "user_id":           userID,
242
        "notification_type": notificationType,
243
        "status":            status,
244
    })
245

246
    return nil
247
}
248

249
// IsEnabled returns whether email functionality is enabled (always true for test service)
250
3x
func (e *TestEmailService) IsEnabled() bool {
251
3x
    return true
252
3x
}
253

254
// getMapKeys returns the keys of a map as a slice of strings
255
1x
func getMapKeys(data map[string]interface{}) []string {
256
1x
    keys := make([]string, 0, len(data))
257
1x
    for k := range data {
258
1x
        keys = append(keys, k)
259
1x
    }
260
1x
    return keys
261
}
262


			
quizapp internal services worker_service.go
66.7%
Statements
26/39
1
//go:build integration
2

3
package services
4

5
import (
6
    "context"
7
    "database/sql"
8
    "os"
9
    "testing"
10

11
    "quizapp/internal/config"
12
    "quizapp/internal/database"
13
    "quizapp/internal/observability"
14

15
    "github.com/stretchr/testify/require"
16
)
17

18
// SharedTestDBSetup provides a clean, isolated database for each integration test
19
// Uses the optimized CleanupTestDatabase function for consistent cleanup
20
194x
func SharedTestDBSetup(t *testing.T) *sql.DB {
21
194x
    observabilityLogger := observability.NewLogger(&config.OpenTelemetryConfig{EnableLogging: false})
22
194x
    dbManager := database.NewManager(observabilityLogger)
23
194x

24
194x
    // Require TEST_DATABASE_URL environment variable to be set
25
194x
    databaseURL := os.Getenv("TEST_DATABASE_URL")
26
194x
    if databaseURL == "" {
27
        t.Fatal("TEST_DATABASE_URL environment variable must be set for integration tests")
28
    }
29

30
194x
    db, err := dbManager.InitDB(databaseURL)
31
194x
    require.NoError(t, err)
32
194x

33
194x
    // Use the optimized cleanup function
34
194x
    CleanupTestDatabase(db, t)
35
194x

36
194x
    return db
37
}
38

39
// cleanupDatabase performs the core database cleanup operations
40
// This is the shared implementation used by both CleanupTestDatabase and SharedTestSuite.Cleanup
41
217x
func cleanupDatabase(db *sql.DB, logger *observability.Logger) {
42
217x
    ctx := context.Background()
43
217x
    tx, err := db.BeginTx(ctx, nil)
44
217x
    if err != nil {
45
        if logger != nil {
46
            logger.Error(ctx, "Failed to begin cleanup transaction", err)
47
        }
48
        return
49
    }
50
217x
    defer func() {
51
217x
        if err != nil {
52
            tx.Rollback()
53
        }
54
    }()
55

56
    // Fast cleanup with batched operations
57
217x
    cleanupQueries := []string{
58
217x
        "TRUNCATE TABLE user_responses CASCADE",
59
217x
        "TRUNCATE TABLE performance_metrics CASCADE",
60
217x
        "TRUNCATE TABLE user_question_metadata CASCADE",
61
217x
        "TRUNCATE TABLE question_priority_scores CASCADE",
62
217x
        "TRUNCATE TABLE user_learning_preferences CASCADE",
63
217x
        "TRUNCATE TABLE user_questions CASCADE",
64
217x
        "TRUNCATE TABLE questions CASCADE",
65
217x
        "TRUNCATE TABLE worker_status CASCADE",
66
217x
        "TRUNCATE TABLE worker_settings CASCADE",
67
217x
        "TRUNCATE TABLE user_api_keys CASCADE",
68
217x
        "TRUNCATE TABLE user_roles CASCADE",
69
217x
        "TRUNCATE TABLE question_reports CASCADE",
70
217x
        "TRUNCATE TABLE notification_errors CASCADE",
71
217x
        "TRUNCATE TABLE upcoming_notifications CASCADE",
72
217x
        "TRUNCATE TABLE sent_notifications CASCADE",
73
217x
        "TRUNCATE TABLE auth_api_keys CASCADE",
74
217x
        "TRUNCATE TABLE daily_question_assignments CASCADE",
75
217x
        "TRUNCATE TABLE story_sections CASCADE",
76
217x
        "TRUNCATE TABLE story_section_questions CASCADE",
77
217x
        "TRUNCATE TABLE stories CASCADE",
78
217x
        "TRUNCATE TABLE snippets CASCADE",
79
217x
        "TRUNCATE TABLE usage_stats CASCADE",
80
217x
        "TRUNCATE TABLE users CASCADE",
81
217x
    }
82
217x

83
217x
    for _, query := range cleanupQueries {
84
4991x
        _, err := tx.ExecContext(ctx, query)
85
4991x
        if err != nil {
86
            if logger != nil {
87
                logger.Warn(ctx, "Could not execute cleanup query", map[string]interface{}{
88
                    "query": query,
89
                })
90
            }
91
        }
92
    }
93

94
    // Reset sequences
95
217x
    sequenceQueries := []string{
96
217x
        "ALTER SEQUENCE users_id_seq RESTART WITH 1",
97
217x
        "ALTER SEQUENCE questions_id_seq RESTART WITH 1",
98
217x
        "ALTER SEQUENCE user_responses_id_seq RESTART WITH 1",
99
217x
        "ALTER SEQUENCE performance_metrics_id_seq RESTART WITH 1",
100
217x
        "ALTER SEQUENCE snippets_id_seq RESTART WITH 1",
101
217x
        "ALTER SEQUENCE auth_api_keys_id_seq RESTART WITH 1",
102
217x
    }
103
217x

104
217x
    for _, query := range sequenceQueries {
105
1302x
        _, err := tx.ExecContext(ctx, query)
106
1302x
        if err != nil {
107
            if logger != nil {
108
                logger.Warn(ctx, "Could not reset sequence", map[string]interface{}{
109
                    "query": query,
110
                })
111
            }
112
        }
113
    }
114

115
    // Re-insert default worker settings
116
217x
    _, err = tx.ExecContext(ctx, `
117
217x
        INSERT INTO worker_settings (setting_key, setting_value, created_at, updated_at)
118
217x
        VALUES ('global_pause', 'false', NOW(), NOW())
119
217x
        ON CONFLICT (setting_key) DO NOTHING;
120
217x
    `)
121
217x
    if err != nil {
122
        if logger != nil {
123
            logger.Error(ctx, "Failed to insert worker settings", err)
124
        }
125
    }
126

127
217x
    err = tx.Commit()
128
217x
    if err != nil {
129
        if logger != nil {
130
            logger.Error(ctx, "Failed to commit cleanup transaction", err)
131
        }
132
    }
133
}
134

135
// CleanupTestDatabase cleans up the database for integration tests
136
// This function can be used by any integration test that needs to clean up the database
137
// Optimized to use batched transactions for better performance
138
217x
func CleanupTestDatabase(db *sql.DB, t *testing.T) {
139
217x
    cleanupDatabase(db, nil)
140
217x
}
141


			
quizapp internal services worker_service.go
50.0%
Statements
32/64
1
package services
2

3
import (
4
    "context"
5
    "crypto/sha256"
6
    "database/sql"
7
    "fmt"
8
    "sync"
9
    "time"
10

11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    contextutils "quizapp/internal/utils"
14

15
    "go.opentelemetry.io/otel/attribute"
16
)
17

18
// TranslationCacheRepository defines the interface for translation cache operations
19
type TranslationCacheRepository interface {
20
    // GetCachedTranslation retrieves a cached translation if it exists and is not expired
21
    GetCachedTranslation(ctx context.Context, textHash, sourceLang, targetLang string) (*models.TranslationCache, error)
22

23
    // SaveTranslation stores a translation in the cache with a 30-day expiration
24
    SaveTranslation(ctx context.Context, textHash, originalText, sourceLang, targetLang, translatedText string) error
25

26
    // CleanupExpiredTranslations removes expired translation cache entries
27
    CleanupExpiredTranslations(ctx context.Context) (int64, error)
28
}
29

30
// TranslationCacheRepositoryImpl implements TranslationCacheRepository
31
type TranslationCacheRepositoryImpl struct {
32
    db     *sql.DB
33
    logger *observability.Logger
34
}
35

36
// NewTranslationCacheRepository creates a new translation cache repository
37
1x
func NewTranslationCacheRepository(db *sql.DB, logger *observability.Logger) TranslationCacheRepository {
38
1x
    return &TranslationCacheRepositoryImpl{
39
1x
        db:     db,
40
1x
        logger: logger,
41
1x
    }
42
1x
}
43

44
// HashText generates a SHA-256 hash of the input text
45
13x
func HashText(text string) string {
46
13x
    hash := sha256.Sum256([]byte(text))
47
13x
    return fmt.Sprintf("%x", hash)
48
13x
}
49

50
// GetCachedTranslation retrieves a cached translation if it exists and is not expired
51
8x
func (r *TranslationCacheRepositoryImpl) GetCachedTranslation(ctx context.Context, textHash, sourceLang, targetLang string) (result *models.TranslationCache, err error) {
52
8x
    ctx, span := observability.TraceDatabaseFunction(ctx, "get_cached_translation",
53
8x
        attribute.String("cache.text_hash", textHash),
54
8x
        attribute.String("cache.source_language", sourceLang),
55
8x
        attribute.String("cache.target_language", targetLang),
56
8x
    )
57
8x
    defer observability.FinishSpan(span, &err)
58
8x

59
8x
    query := `
60
8x
        SELECT id, text_hash, original_text, source_language, target_language,
61
8x
               translated_text, created_at, expires_at
62
8x
        FROM translation_cache
63
8x
        WHERE text_hash = $1
64
8x
          AND source_language = $2
65
8x
          AND target_language = $3
66
8x
          AND expires_at > NOW()
67
8x
    `
68
8x

69
8x
    cache := &models.TranslationCache{}
70
8x
    err = r.db.QueryRowContext(ctx, query, textHash, sourceLang, targetLang).Scan(
71
8x
        &cache.ID,
72
8x
        &cache.TextHash,
73
8x
        &cache.OriginalText,
74
8x
        &cache.SourceLanguage,
75
8x
        &cache.TargetLanguage,
76
8x
        &cache.TranslatedText,
77
8x
        &cache.CreatedAt,
78
8x
        &cache.ExpiresAt,
79
8x
    )
80
8x

81
8x
    if err == sql.ErrNoRows {
82
2x
        span.SetAttributes(attribute.Bool("cache.found", false))
83
2x
        return nil, nil // Not found or expired
84
2x
    }
85

86
6x
    if err != nil {
87
        err = contextutils.WrapError(err, "failed to query translation cache")
88
        return nil, err
89
    }
90

91
6x
    span.SetAttributes(attribute.Bool("cache.found", true))
92
6x
    return cache, nil
93
}
94

95
// SaveTranslation stores a translation in the cache with a 30-day expiration
96
7x
func (r *TranslationCacheRepositoryImpl) SaveTranslation(ctx context.Context, textHash, originalText, sourceLang, targetLang, translatedText string) (err error) {
97
7x
    ctx, span := observability.TraceDatabaseFunction(ctx, "save_translation_cache",
98
7x
        attribute.String("cache.text_hash", textHash),
99
7x
        attribute.String("cache.source_language", sourceLang),
100
7x
        attribute.String("cache.target_language", targetLang),
101
7x
        attribute.Int("cache.original_text_length", len(originalText)),
102
7x
        attribute.Int("cache.translated_text_length", len(translatedText)),
103
7x
    )
104
7x
    defer observability.FinishSpan(span, &err)
105
7x

106
7x
    expiresAt := time.Now().Add(30 * 24 * time.Hour) // 30 days from now
107
7x

108
7x
    query := `
109
7x
        INSERT INTO translation_cache (text_hash, original_text, source_language, target_language, translated_text, expires_at)
110
7x
        VALUES ($1, $2, $3, $4, $5, $6)
111
7x
        ON CONFLICT (text_hash, source_language, target_language)
112
7x
        DO UPDATE SET
113
7x
            translated_text = EXCLUDED.translated_text,
114
7x
            expires_at = EXCLUDED.expires_at,
115
7x
            created_at = CURRENT_TIMESTAMP
116
7x
    `
117
7x

118
7x
    _, err = r.db.ExecContext(ctx, query, textHash, originalText, sourceLang, targetLang, translatedText, expiresAt)
119
7x
    if err != nil {
120
        err = contextutils.WrapError(err, "failed to save translation to cache")
121
        return err
122
    }
123

124
7x
    span.SetAttributes(
125
7x
        attribute.String("cache.expires_at", expiresAt.Format(time.RFC3339)),
126
7x
    )
127
7x

128
7x
    return nil
129
}
130

131
// CleanupExpiredTranslations removes expired translation cache entries
132
1x
func (r *TranslationCacheRepositoryImpl) CleanupExpiredTranslations(ctx context.Context) (count int64, err error) {
133
1x
    ctx, span := observability.TraceDatabaseFunction(ctx, "cleanup_expired_translations")
134
1x
    defer observability.FinishSpan(span, &err)
135
1x

136
1x
    query := `DELETE FROM translation_cache WHERE expires_at < NOW()`
137
1x

138
1x
    result, err := r.db.ExecContext(ctx, query)
139
1x
    if err != nil {
140
        err = contextutils.WrapError(err, "failed to cleanup expired translations")
141
        return 0, err
142
    }
143

144
1x
    rowsAffected, err := result.RowsAffected()
145
1x
    if err != nil {
146
        err = contextutils.WrapError(err, "failed to get rows affected")
147
        return 0, err
148
    }
149

150
1x
    span.SetAttributes(attribute.Int64("cache.deleted_count", rowsAffected))
151
1x
    r.logger.Info(ctx, "Cleaned up expired translation cache entries", map[string]interface{}{
152
1x
        "deleted_count": rowsAffected,
153
1x
    })
154
1x

155
1x
    return rowsAffected, nil
156
}
157

158
// InMemoryTranslationCacheRepository is an in-memory implementation for testing
159
type InMemoryTranslationCacheRepository struct {
160
    cache map[string]*models.TranslationCache
161
    mu    sync.RWMutex
162
}
163

164
// NewInMemoryTranslationCacheRepository creates a new in-memory translation cache repository
165
func NewInMemoryTranslationCacheRepository() *InMemoryTranslationCacheRepository {
166
    return &InMemoryTranslationCacheRepository{
167
        cache: make(map[string]*models.TranslationCache),
168
    }
169
}
170

171
// GetCachedTranslation retrieves a cached translation from the in-memory cache
172
func (m *InMemoryTranslationCacheRepository) GetCachedTranslation(_ context.Context, textHash, sourceLang, targetLang string) (*models.TranslationCache, error) {
173
    m.mu.RLock()
174
    defer m.mu.RUnlock()
175

176
    key := textHash + "|" + sourceLang + "|" + targetLang
177
    cached, exists := m.cache[key]
178
    if !exists {
179
        return nil, nil
180
    }
181

182
    // Check if expired
183
    if time.Now().After(cached.ExpiresAt) {
184
        return nil, nil
185
    }
186

187
    return cached, nil
188
}
189

190
// SaveTranslation saves a translation to the in-memory cache
191
func (m *InMemoryTranslationCacheRepository) SaveTranslation(_ context.Context, textHash, originalText, sourceLang, targetLang, translatedText string) error {
192
    m.mu.Lock()
193
    defer m.mu.Unlock()
194

195
    key := textHash + "|" + sourceLang + "|" + targetLang
196
    m.cache[key] = &models.TranslationCache{
197
        TextHash:       textHash,
198
        OriginalText:   originalText,
199
        SourceLanguage: sourceLang,
200
        TargetLanguage: targetLang,
201
        TranslatedText: translatedText,
202
        CreatedAt:      time.Now(),
203
        ExpiresAt:      time.Now().Add(30 * 24 * time.Hour),
204
    }
205

206
    return nil
207
}
208

209
// CleanupExpiredTranslations removes expired entries from the in-memory cache
210
func (m *InMemoryTranslationCacheRepository) CleanupExpiredTranslations(_ context.Context) (int64, error) {
211
    m.mu.Lock()
212
    defer m.mu.Unlock()
213

214
    now := time.Now()
215
    deleted := int64(0)
216

217
    for key, cached := range m.cache {
218
        if now.After(cached.ExpiresAt) {
219
            delete(m.cache, key)
220
            deleted++
221
        }
222
    }
223

224
    return deleted, nil
225
}
226


			
quizapp internal services worker_service.go
19.5%
Statements
68/348
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "fmt"
8
    "math/rand"
9
    "os"
10
    "path/filepath"
11
    "regexp"
12
    "strings"
13
    "sync"
14
    "time"
15

16
    "quizapp/internal/config"
17
    "quizapp/internal/models"
18
    "quizapp/internal/observability"
19
    contextutils "quizapp/internal/utils"
20

21
    "github.com/neurosnap/sentences"
22
    sentencesdata "github.com/neurosnap/sentences/data"
23
    "go.opentelemetry.io/otel/attribute"
24
)
25

26
// TranslationPracticeServiceInterface defines the interface for translation practice operations
27
type TranslationPracticeServiceInterface interface {
28
    GenerateSentence(ctx context.Context, userID uint, req *models.GenerateSentenceRequest, aiService AIServiceInterface, userAIConfig *models.UserAIConfig) (*models.TranslationPracticeSentence, error)
29
    GetSentenceFromExistingContent(ctx context.Context, userID uint, language, level string, direction models.TranslationDirection) (*models.TranslationPracticeSentence, error)
30
    SubmitTranslation(ctx context.Context, userID uint, req *models.SubmitTranslationRequest, aiService AIServiceInterface, userAIConfig *models.UserAIConfig) (*models.TranslationPracticeSession, error)
31
    GetPracticeHistory(ctx context.Context, userID uint, limit, offset int, search string) ([]models.TranslationPracticeSession, int, error)
32
    GetPracticeStats(ctx context.Context, userID uint) (map[string]interface{}, error)
33
    DeleteAllPracticeHistoryForUser(ctx context.Context, userID uint) error
34
}
35

36
// TranslationPracticeService handles translation practice operations
37
type TranslationPracticeService struct {
38
    db              *sql.DB
39
    storyService    StoryServiceInterface
40
    questionService QuestionServiceInterface
41
    config          *config.Config
42
    logger          *observability.Logger
43
    templateManager *AITemplateManager
44
    punktModels     map[string]*sentences.DefaultSentenceTokenizer
45
    punktModelsMu   sync.RWMutex
46
    punktModelDir   string
47
}
48

49
// NewTranslationPracticeService creates a new TranslationPracticeService instance
50
func NewTranslationPracticeService(
51
    db *sql.DB,
52
    storyService StoryServiceInterface,
53
    questionService QuestionServiceInterface,
54
    config *config.Config,
55
    logger *observability.Logger,
56
) *TranslationPracticeService {
57
    // Create template manager
58
    templateManager, err := NewAITemplateManager()
59
    if err != nil {
60
        logger.Error(context.Background(), "Failed to create template manager", err, map[string]interface{}{})
61
        panic(err) // Use panic for fatal errors in initialization
62
    }
63

64
    // Determine punkt model directory (relative to repo root)
65
    pwd, err := os.Getwd()
66
    if err != nil {
67
        logger.Error(context.Background(), "Failed to get working directory for Punkt models", err, map[string]interface{}{})
68
    }
69
    // Find repo root by looking for go.mod
70
    repoRoot := pwd
71
    for {
72
        if _, err := os.Stat(filepath.Join(repoRoot, "go.mod")); err == nil {
73
            break
74
        }
75
        parent := filepath.Dir(repoRoot)
76
        if parent == repoRoot {
77
            // Reached filesystem root
78
            repoRoot = pwd
79
            break
80
        }
81
        repoRoot = parent
82
    }
83
    punktModelDir := filepath.Join(repoRoot, "backend", "internal", "resources", "punkt")
84

85
    return &TranslationPracticeService{
86
        db:              db,
87
        storyService:    storyService,
88
        questionService: questionService,
89
        config:          config,
90
        logger:          logger,
91
        templateManager: templateManager,
92
        punktModels:     make(map[string]*sentences.DefaultSentenceTokenizer),
93
        punktModelDir:   punktModelDir,
94
    }
95
}
96

97
// GenerateSentence generates a new sentence using AI
98
func (s *TranslationPracticeService) GenerateSentence(
99
    ctx context.Context,
100
    userID uint,
101
    req *models.GenerateSentenceRequest,
102
    aiService AIServiceInterface,
103
    userAIConfig *models.UserAIConfig,
104
) (result0 *models.TranslationPracticeSentence, err error) {
105
    ctx, span := observability.TraceAIFunction(ctx, "generate_translation_sentence",
106
        attribute.Int("user_id", int(userID)),
107
        attribute.String("language", req.Language),
108
        attribute.String("level", req.Level),
109
        attribute.String("direction", string(req.Direction)),
110
    )
111
    defer observability.FinishSpan(span, &err)
112

113
    // Determine source and target languages based on direction
114
    var sourceLang, targetLang string
115
    if req.Direction == models.TranslationDirectionEnToLearning {
116
        sourceLang = "en"
117
        targetLang = req.Language
118
    } else {
119
        sourceLang = req.Language
120
        targetLang = "en"
121
    }
122

123
    // Build prompt for sentence generation
124
    templateData := AITemplateData{
125
        Language:  req.Language,
126
        Level:     req.Level,
127
        Topic:     stringPtrToString(req.Topic),
128
        Direction: string(req.Direction),
129
    }
130

131
    // Get template manager from AI service (we'll need to expose this or create our own)
132
    // For now, we'll use the AI service's method to generate the sentence
133
    prompt := s.buildTranslationSentencePrompt(templateData)
134

135
    // Call AI service
136
    response, err := aiService.CallWithPrompt(ctx, userAIConfig, prompt, "")
137
    if err != nil {
138
        return nil, contextutils.WrapErrorf(err, "failed to generate sentence")
139
    }
140

141
    // Clean the response - remove any markdown, quotes, or extra whitespace
142
    sentenceText := s.cleanSentenceResponse(response)
143

144
    // Save the sentence to database
145
    sentence := &models.TranslationPracticeSentence{
146
        UserID:         userID,
147
        SentenceText:   sentenceText,
148
        SourceLanguage: sourceLang,
149
        TargetLanguage: targetLang,
150
        LanguageLevel:  req.Level,
151
        SourceType:     models.SentenceSourceTypeAIGenerated,
152
        Topic:          req.Topic,
153
        CreatedAt:      time.Now(),
154
        UpdatedAt:      time.Now(),
155
    }
156

157
    if err := s.saveSentence(ctx, sentence); err != nil {
158
        return nil, contextutils.WrapErrorf(err, "failed to save sentence")
159
    }
160

161
    return sentence, nil
162
}
163

164
// GetSentenceFromExistingContent retrieves a sentence from existing content
165
func (s *TranslationPracticeService) GetSentenceFromExistingContent(
166
    ctx context.Context,
167
    userID uint,
168
    language, level string,
169
    direction models.TranslationDirection,
170
) (result0 *models.TranslationPracticeSentence, err error) {
171
    ctx, span := observability.TraceFunction(ctx, "translation_practice", "get_sentence_from_existing_content",
172
        attribute.Int("user_id", int(userID)),
173
        attribute.String("language", language),
174
        attribute.String("level", level),
175
        attribute.String("direction", string(direction)),
176
    )
177
    defer observability.FinishSpan(span, &err)
178

179
    // Determine source and target languages
180
    var sourceLang, targetLang string
181
    if direction == models.TranslationDirectionEnToLearning {
182
        sourceLang = "en"
183
        targetLang = language
184
    } else {
185
        sourceLang = language
186
        targetLang = "en"
187
    }
188

189
    // Try different sources in order of preference
190
    sources := []struct {
191
        sourceType models.SentenceSourceType
192
        fetcher    func() (*models.TranslationPracticeSentence, error)
193
    }{
194
        {
195
            sourceType: models.SentenceSourceTypeStorySection,
196
            fetcher: func() (*models.TranslationPracticeSentence, error) {
197
                return s.getSentenceFromStory(ctx, userID, language, level, sourceLang, targetLang)
198
            },
199
        },
200
        {
201
            sourceType: models.SentenceSourceTypeVocabularyQuestion,
202
            fetcher: func() (*models.TranslationPracticeSentence, error) {
203
                return s.getSentenceFromVocabulary(ctx, userID, language, level, sourceLang, targetLang)
204
            },
205
        },
206
        {
207
            sourceType: models.SentenceSourceTypeReadingComprehension,
208
            fetcher: func() (*models.TranslationPracticeSentence, error) {
209
                return s.getSentenceFromReadingComprehension(ctx, userID, language, level, sourceLang, targetLang)
210
            },
211
        },
212
    }
213

214
    // Try each source until we find one
215
    for _, source := range sources {
216
        sentence, err := source.fetcher()
217
        if err == nil && sentence != nil {
218
            // Save to database if not already saved
219
            if sentence.ID == 0 {
220
                if err := s.saveSentence(ctx, sentence); err != nil {
221
                    s.logger.Warn(ctx, "Failed to save sentence from existing content", map[string]interface{}{
222
                        "error":       err.Error(),
223
                        "source_type": string(source.sourceType),
224
                    })
225
                    // Continue to next source
226
                    continue
227
                }
228
            }
229
            return sentence, nil
230
        }
231
        s.logger.Debug(ctx, "Failed to get sentence from source", map[string]interface{}{
232
            "source_type": string(source.sourceType),
233
            "error":       err.Error(),
234
        })
235
    }
236

237
    return nil, contextutils.NewAppError(
238
        contextutils.ErrorCodeRecordNotFound,
239
        contextutils.SeverityWarn,
240
        "No suitable sentences found in existing content",
241
        "",
242
    )
243
}
244

245
// SubmitTranslation submits a translation for AI evaluation
246
func (s *TranslationPracticeService) SubmitTranslation(
247
    ctx context.Context,
248
    userID uint,
249
    req *models.SubmitTranslationRequest,
250
    aiService AIServiceInterface,
251
    userAIConfig *models.UserAIConfig,
252
) (result0 *models.TranslationPracticeSession, err error) {
253
    ctx, span := observability.TraceFunction(ctx, "translation_practice", "submit_translation",
254
        attribute.Int("user_id", int(userID)),
255
        attribute.Int("sentence_id", int(req.SentenceID)),
256
    )
257
    defer observability.FinishSpan(span, &err)
258

259
    // Get the sentence
260
    sentence, err := s.getSentenceByID(ctx, req.SentenceID, userID)
261
    if err != nil {
262
        return nil, contextutils.WrapErrorf(err, "failed to get sentence")
263
    }
264

265
    // Get user's language and level for context
266
    user, err := s.getUserByID(ctx, userID)
267
    if err != nil {
268
        return nil, contextutils.WrapErrorf(err, "failed to get user")
269
    }
270

271
    // Build evaluation prompt
272
    lang := ""
273
    if user.PreferredLanguage.Valid {
274
        lang = user.PreferredLanguage.String
275
    }
276
    lvl := ""
277
    if user.CurrentLevel.Valid {
278
        lvl = user.CurrentLevel.String
279
    }
280
    templateData := AITemplateData{
281
        Language:             lang,
282
        Level:                lvl,
283
        OriginalSentence:     req.OriginalSentence,
284
        UserTranslation:      req.UserTranslation,
285
        SourceLanguage:       sentence.SourceLanguage,
286
        TargetLanguage:       sentence.TargetLanguage,
287
        TranslationDirection: string(req.TranslationDirection),
288
    }
289

290
    prompt := s.buildTranslationEvaluationPrompt(templateData)
291

292
    // Call AI service for evaluation
293
    feedback, err := aiService.CallWithPrompt(ctx, userAIConfig, prompt, "")
294
    if err != nil {
295
        return nil, contextutils.WrapErrorf(err, "failed to evaluate translation")
296
    }
297

298
    // Extract score from feedback
299
    score := s.extractScoreFromFeedback(feedback)
300
    cleanFeedback := s.cleanFeedbackResponse(feedback)
301

302
    // Create session
303
    session := &models.TranslationPracticeSession{
304
        UserID:               userID,
305
        SentenceID:           req.SentenceID,
306
        OriginalSentence:     req.OriginalSentence,
307
        UserTranslation:      req.UserTranslation,
308
        TranslationDirection: req.TranslationDirection,
309
        AIFeedback:           cleanFeedback,
310
        AIScore:              score,
311
        CreatedAt:            time.Now(),
312
    }
313

314
    if err := s.saveSession(ctx, session); err != nil {
315
        return nil, contextutils.WrapErrorf(err, "failed to save session")
316
    }
317

318
    return session, nil
319
}
320

321
// GetPracticeHistory retrieves practice history for a user with pagination
322
func (s *TranslationPracticeService) GetPracticeHistory(
323
    ctx context.Context,
324
    userID uint,
325
    limit int,
326
    offset int,
327
    search string,
328
) (result0 []models.TranslationPracticeSession, total int, err error) {
329
    ctx, span := observability.TraceFunction(ctx, "translation_practice", "get_practice_history",
330
        attribute.Int("user_id", int(userID)),
331
        attribute.Int("limit", limit),
332
        attribute.Int("offset", offset),
333
        attribute.String("search", search),
334
    )
335
    defer observability.FinishSpan(span, &err)
336

337
    // Build base WHERE clause
338
    whereClause := "WHERE user_id = $1"
339
    args := []interface{}{userID}
340
    argIndex := 2
341

342
    // Add search filter if provided
343
    if search != "" {
344
        searchPattern := "%" + strings.ToLower(strings.TrimSpace(search)) + "%"
345
        whereClause += fmt.Sprintf(`
346
            AND (
347
                LOWER(original_sentence) LIKE $%d OR
348
                LOWER(user_translation) LIKE $%d OR
349
                LOWER(ai_feedback) LIKE $%d OR
350
                LOWER(translation_direction) LIKE $%d
351
            )`, argIndex, argIndex, argIndex, argIndex)
352
        args = append(args, searchPattern)
353
        argIndex++
354
    }
355

356
    // Get total count
357
    countQuery := `
358
        SELECT COUNT(*)
359
        FROM translation_practice_sessions
360
        ` + whereClause
361
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total)
362
    if err != nil {
363
        return nil, 0, contextutils.WrapErrorf(err, "failed to count practice history")
364
    }
365

366
    // Get paginated results
367
    query := `
368
        SELECT id, user_id, sentence_id, original_sentence, user_translation,
369
               translation_direction, ai_feedback, ai_score, created_at
370
        FROM translation_practice_sessions
371
        ` + whereClause + `
372
        ORDER BY created_at DESC
373
        LIMIT $` + fmt.Sprintf("%d", argIndex) + `
374
        OFFSET $` + fmt.Sprintf("%d", argIndex+1) + `
375
    `
376
    args = append(args, limit, offset)
377

378
    rows, err := s.db.QueryContext(ctx, query, args...)
379
    if err != nil {
380
        return nil, 0, contextutils.WrapErrorf(err, "failed to query practice history")
381
    }
382
    defer func() {
383
        if closeErr := rows.Close(); closeErr != nil {
384
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": closeErr.Error()})
385
        }
386
    }()
387

388
    var sessions []models.TranslationPracticeSession
389
    for rows.Next() {
390
        var session models.TranslationPracticeSession
391
        var aiScore sql.NullFloat64
392

393
        err := rows.Scan(
394
            &session.ID,
395
            &session.UserID,
396
            &session.SentenceID,
397
            &session.OriginalSentence,
398
            &session.UserTranslation,
399
            &session.TranslationDirection,
400
            &session.AIFeedback,
401
            &aiScore,
402
            &session.CreatedAt,
403
        )
404
        if err != nil {
405
            return nil, 0, contextutils.WrapErrorf(err, "failed to scan session")
406
        }
407

408
        if aiScore.Valid {
409
            score := aiScore.Float64
410
            session.AIScore = &score
411
        }
412

413
        sessions = append(sessions, session)
414
    }
415

416
    return sessions, total, nil
417
}
418

419
// GetPracticeStats retrieves practice statistics for a user
420
func (s *TranslationPracticeService) GetPracticeStats(
421
    ctx context.Context,
422
    userID uint,
423
) (result0 map[string]interface{}, err error) {
424
    ctx, span := observability.TraceFunction(ctx, "translation_practice", "get_practice_stats",
425
        attribute.Int("user_id", int(userID)),
426
    )
427
    defer observability.FinishSpan(span, &err)
428

429
    query := `
430
        SELECT
431
            COUNT(*) as total_sessions,
432
            AVG(ai_score) as average_score,
433
            MIN(ai_score) as min_score,
434
            MAX(ai_score) as max_score,
435
            COUNT(CASE WHEN ai_score >= 4.0 THEN 1 END) as excellent_count,
436
            COUNT(CASE WHEN ai_score >= 3.0 AND ai_score < 4.0 THEN 1 END) as good_count,
437
            COUNT(CASE WHEN ai_score < 3.0 THEN 1 END) as needs_improvement_count
438
        FROM translation_practice_sessions
439
        WHERE user_id = $1 AND ai_score IS NOT NULL
440
    `
441

442
    var stats struct {
443
        TotalSessions         int
444
        AverageScore          sql.NullFloat64
445
        MinScore              sql.NullFloat64
446
        MaxScore              sql.NullFloat64
447
        ExcellentCount        int
448
        GoodCount             int
449
        NeedsImprovementCount int
450
    }
451

452
    err = s.db.QueryRowContext(ctx, query, userID).Scan(
453
        &stats.TotalSessions,
454
        &stats.AverageScore,
455
        &stats.MinScore,
456
        &stats.MaxScore,
457
        &stats.ExcellentCount,
458
        &stats.GoodCount,
459
        &stats.NeedsImprovementCount,
460
    )
461
    if err != nil {
462
        if errors.Is(err, sql.ErrNoRows) {
463
            return map[string]interface{}{
464
                "total_sessions":          0,
465
                "average_score":           nil,
466
                "min_score":               nil,
467
                "max_score":               nil,
468
                "excellent_count":         0,
469
                "good_count":              0,
470
                "needs_improvement_count": 0,
471
            }, nil
472
        }
473
        return nil, contextutils.WrapErrorf(err, "failed to query stats")
474
    }
475

476
    result := map[string]interface{}{
477
        "total_sessions":          stats.TotalSessions,
478
        "excellent_count":         stats.ExcellentCount,
479
        "good_count":              stats.GoodCount,
480
        "needs_improvement_count": stats.NeedsImprovementCount,
481
    }
482

483
    if stats.AverageScore.Valid {
484
        result["average_score"] = stats.AverageScore.Float64
485
    } else {
486
        result["average_score"] = nil
487
    }
488

489
    if stats.MinScore.Valid {
490
        result["min_score"] = stats.MinScore.Float64
491
    } else {
492
        result["min_score"] = nil
493
    }
494

495
    if stats.MaxScore.Valid {
496
        result["max_score"] = stats.MaxScore.Float64
497
    } else {
498
        result["max_score"] = nil
499
    }
500

501
    return result, nil
502
}
503

504
// DeleteAllPracticeHistoryForUser deletes all translation practice sessions for a given user
505
func (s *TranslationPracticeService) DeleteAllPracticeHistoryForUser(ctx context.Context, userID uint) error {
506
    ctx, span := observability.TraceFunction(ctx, "translation_practice", "delete_all_practice_history_for_user",
507
        attribute.Int("user_id", int(userID)),
508
    )
509
    defer observability.FinishSpan(span, nil)
510

511
    tx, err := s.db.BeginTx(ctx, nil)
512
    if err != nil {
513
        return contextutils.WrapErrorf(err, "failed to begin transaction")
514
    }
515
    defer func() { _ = tx.Rollback() }()
516

517
    // Delete all translation practice sessions for this user
518
    query := `DELETE FROM translation_practice_sessions WHERE user_id = $1`
519
    result, err := tx.ExecContext(ctx, query, userID)
520
    if err != nil {
521
        return contextutils.WrapErrorf(err, "failed to delete translation practice sessions for user %d", userID)
522
    }
523

524
    rowsAffected, err := result.RowsAffected()
525
    if err != nil {
526
        s.logger.Warn(ctx, "Failed to get rows affected count", map[string]interface{}{"error": err.Error()})
527
    } else {
528
        s.logger.Info(ctx, "Deleted translation practice sessions for user", map[string]interface{}{
529
            "user_id":       userID,
530
            "rows_affected": rowsAffected,
531
        })
532
    }
533

534
    if err := tx.Commit(); err != nil {
535
        return contextutils.WrapErrorf(err, "failed to commit delete all translation practice history transaction for user %d", userID)
536
    }
537

538
    return nil
539
}
540

541
// Helper methods
542

543
func (s *TranslationPracticeService) buildTranslationSentencePrompt(data AITemplateData) string {
544
    prompt, err := s.templateManager.RenderTemplate(TranslationSentencePromptTemplate, data)
545
    if err != nil {
546
        s.logger.Error(context.Background(), "Failed to render translation sentence template", err, map[string]interface{}{})
547
        // Fallback to simple prompt
548
        prompt = fmt.Sprintf("Generate a single sentence in %s at %s level for translation practice.", data.Language, data.Level)
549
        if data.Topic != "" {
550
            prompt += fmt.Sprintf(" Topic/Keywords: %s", data.Topic)
551
        }
552
        prompt += " Return ONLY the sentence text, nothing else."
553
    }
554
    return prompt
555
}
556

557
func (s *TranslationPracticeService) buildTranslationEvaluationPrompt(data AITemplateData) string {
558
    prompt, err := s.templateManager.RenderTemplate(TranslationEvaluationPromptTemplate, data)
559
    if err != nil {
560
        s.logger.Error(context.Background(), "Failed to render translation evaluation template", err, map[string]interface{}{})
561
        // Fallback to simple prompt
562
        prompt = fmt.Sprintf(`You are an expert language teacher evaluating a translation.
563

564
A user is learning %s at the %s level.
565

566
Original sentence (%s): "%s"
567
User's translation (%s): "%s"
568
Translation direction: %s
569

570
Evaluate the translation and provide detailed, educational feedback. Focus on accuracy, grammar, naturalness, word choice, and cultural context. At the end, provide a score from 0 to 5 in this format: SCORE: [number]
571

572
`, data.Language, data.Level, data.SourceLanguage, data.OriginalSentence, data.TargetLanguage, data.UserTranslation, data.TranslationDirection)
573
    }
574
    return prompt
575
}
576

577
func (s *TranslationPracticeService) cleanSentenceResponse(response string) string {
578
    // Remove markdown code blocks
579
    response = regexp.MustCompile("(?s)```[^`]*```").ReplaceAllString(response, "")
580
    // Strip leading and trailing quotes/brackets consistently
581
    response = s.stripQuotes(response)
582
    // Trim whitespace
583
    response = strings.TrimSpace(response)
584
    return response
585
}
586

587
func (s *TranslationPracticeService) extractScoreFromFeedback(feedback string) *float64 {
588
    // Look for "SCORE: X" pattern
589
    re := regexp.MustCompile(`(?i)SCORE:\s*([0-9]+\.?[0-9]*)`)
590
    matches := re.FindStringSubmatch(feedback)
591
    if len(matches) > 1 {
592
        if score, err := parseFloat(matches[1]); err == nil {
593
            // Clamp score between 0 and 5
594
            if score < 0 {
595
                score = 0
596
            }
597
            if score > 5 {
598
                score = 5
599
            }
600
            return &score
601
        }
602
    }
603
    return nil
604
}
605

606
func parseFloat(s string) (float64, error) {
607
    var f float64
608
    _, err := fmt.Sscanf(s, "%f", &f)
609
    return f, err
610
}
611

612
func (s *TranslationPracticeService) cleanFeedbackResponse(feedback string) string {
613
    // Remove the score line if present
614
    feedback = regexp.MustCompile(`(?i)SCORE:\s*[0-9]+\.?[0-9]*\s*`).ReplaceAllString(feedback, "")
615
    return strings.TrimSpace(feedback)
616
}
617

618
func (s *TranslationPracticeService) getSentenceFromStory(
619
    ctx context.Context,
620
    userID uint,
621
    language, level string,
622
    sourceLang, targetLang string,
623
) (*models.TranslationPracticeSentence, error) {
624
    // Get user's current story
625
    story, err := s.storyService.GetCurrentStory(ctx, userID)
626
    if err != nil {
627
        return nil, err
628
    }
629

630
    if story == nil || len(story.Sections) == 0 {
631
        return nil, errors.New("no story sections available")
632
    }
633

634
    // Filter sections by language and level
635
    var suitableSections []models.StorySection
636
    for _, section := range story.Sections {
637
        if section.LanguageLevel == level {
638
            suitableSections = append(suitableSections, section)
639
        }
640
    }
641

642
    if len(suitableSections) == 0 {
643
        return nil, errors.New("no suitable story sections found")
644
    }
645

646
    // Pick a random section
647
    section := suitableSections[rand.Intn(len(suitableSections))]
648

649
    // Extract a sentence from the section content
650
    sentences := s.extractSentences(section.Content, language)
651
    if len(sentences) == 0 {
652
        return nil, errors.New("no sentences found in story section")
653
    }
654

655
    // Pick a random sentence
656
    sentenceText := sentences[rand.Intn(len(sentences))]
657

658
    return &models.TranslationPracticeSentence{
659
        UserID:         userID,
660
        SentenceText:   sentenceText,
661
        SourceLanguage: sourceLang,
662
        TargetLanguage: targetLang,
663
        LanguageLevel:  level,
664
        SourceType:     models.SentenceSourceTypeStorySection,
665
        SourceID:       &section.ID,
666
        CreatedAt:      time.Now(),
667
        UpdatedAt:      time.Now(),
668
    }, nil
669
}
670

671
func (s *TranslationPracticeService) getSentenceFromVocabulary(
672
    ctx context.Context,
673
    userID uint,
674
    language, level string,
675
    sourceLang, targetLang string,
676
) (*models.TranslationPracticeSentence, error) {
677
    // Get vocabulary questions for the user
678
    questions, err := s.questionService.GetQuestionsByFilter(
679
        ctx,
680
        int(userID),
681
        language,
682
        level,
683
        models.Vocabulary,
684
        10, // Get 10 questions to choose from
685
    )
686
    if err != nil {
687
        return nil, err
688
    }
689

690
    if len(questions) == 0 {
691
        return nil, errors.New("no vocabulary questions available")
692
    }
693

694
    // Pick a random question
695
    question := questions[rand.Intn(len(questions))]
696

697
    // Extract sentence from question content
698
    var sentenceText string
699
    if content, ok := question.Content["sentence"].(string); ok {
700
        sentenceText = content
701
    } else {
702
        return nil, errors.New("no sentence found in vocabulary question")
703
    }
704

705
    questionID := uint(question.ID)
706
    return &models.TranslationPracticeSentence{
707
        UserID:         userID,
708
        SentenceText:   sentenceText,
709
        SourceLanguage: sourceLang,
710
        TargetLanguage: targetLang,
711
        LanguageLevel:  level,
712
        SourceType:     models.SentenceSourceTypeVocabularyQuestion,
713
        SourceID:       &questionID,
714
        CreatedAt:      time.Now(),
715
        UpdatedAt:      time.Now(),
716
    }, nil
717
}
718

719
func (s *TranslationPracticeService) getSentenceFromReadingComprehension(
720
    ctx context.Context,
721
    userID uint,
722
    language, level string,
723
    sourceLang, targetLang string,
724
) (*models.TranslationPracticeSentence, error) {
725
    // Get reading comprehension questions
726
    questions, err := s.questionService.GetQuestionsByFilter(
727
        ctx,
728
        int(userID),
729
        language,
730
        level,
731
        models.ReadingComprehension,
732
        10,
733
    )
734
    if err != nil {
735
        return nil, err
736
    }
737

738
    if len(questions) == 0 {
739
        return nil, errors.New("no reading comprehension questions available")
740
    }
741

742
    // Pick a random question
743
    question := questions[rand.Intn(len(questions))]
744

745
    // Extract a sentence from the passage
746
    var passageText string
747
    if content, ok := question.Content["passage"].(string); ok {
748
        passageText = content
749
    } else {
750
        return nil, errors.New("no passage found in reading comprehension question")
751
    }
752

753
    sentences := s.extractSentences(passageText, language)
754
    if len(sentences) == 0 {
755
        return nil, errors.New("no sentences found in passage")
756
    }
757

758
    // Pick a random sentence
759
    sentenceText := sentences[rand.Intn(len(sentences))]
760

761
    questionID := uint(question.ID)
762
    return &models.TranslationPracticeSentence{
763
        UserID:         userID,
764
        SentenceText:   sentenceText,
765
        SourceLanguage: sourceLang,
766
        TargetLanguage: targetLang,
767
        LanguageLevel:  level,
768
        SourceType:     models.SentenceSourceTypeReadingComprehension,
769
        SourceID:       &questionID,
770
        CreatedAt:      time.Now(),
771
        UpdatedAt:      time.Now(),
772
    }, nil
773
}
774

775
// getPunktModelName maps language codes to Punkt model file names
776
// Handles both language codes (e.g., "it") and full names (e.g., "italian")
777
58x
func (s *TranslationPracticeService) getPunktModelName(code string) string {
778
58x
    switch code {
779
34x
    case "en", "english":
780
34x
        return "english"
781
1x
    case "it", "italian":
782
1x
        return "italian"
783
1x
    case "fr", "french":
784
1x
        return "french"
785
1x
    case "de", "german":
786
1x
        return "german"
787
1x
    case "es", "spanish":
788
1x
        return "spanish"
789
7x
    case "ru", "russian":
790
7x
        return "russian"
791
3x
    case "hi", "hindi":
792
3x
        return "hindi"
793
2x
    case "ja", "japanese":
794
2x
        return "japanese"
795
4x
    case "zh", "chinese":
796
4x
        return "chinese"
797
4x
    default:
798
4x
        return ""
799
    }
800
}
801

802
// getPunktModel loads and caches a Punkt model for the given language code.
803
// Returns nil if no model is available (caller should use regex fallback).
804
47x
func (s *TranslationPracticeService) getPunktModel(languageCode string) *sentences.DefaultSentenceTokenizer {
805
47x
    modelName := s.getPunktModelName(languageCode)
806
47x
    if modelName == "" {
807
2x
        return nil
808
2x
    }
809

810
    // Check cache first (read lock)
811
45x
    s.punktModelsMu.RLock()
812
45x
    if model, exists := s.punktModels[languageCode]; exists {
813
        s.punktModelsMu.RUnlock()
814
        return model
815
    }
816
45x
    s.punktModelsMu.RUnlock()
817
45x

818
45x
    // Try to load from file (write lock)
819
45x
    s.punktModelsMu.Lock()
820
45x
    defer s.punktModelsMu.Unlock()
821
45x

822
45x
    // Double-check after acquiring write lock
823
45x
    if model, exists := s.punktModels[languageCode]; exists {
824
        return model
825
    }
826

827
    // Try built-in English model first
828
45x
    var trainingData []byte
829
45x
    if languageCode == "en" {
830
33x
        // Use built-in embedded English model
831
33x
        var err error
832
33x
        trainingData, err = sentencesdata.Asset("english.json")
833
33x
        if err != nil {
834
33x
            // Fallback: try loading from file
835
33x
            modelPath := filepath.Join(s.punktModelDir, "english.json")
836
33x
            if data, err := os.ReadFile(modelPath); err == nil {
837
                trainingData = data
838
            }
839
        }
840
12x
    } else {
841
12x
        // Try loading from JSON file for other languages
842
12x
        modelPath := filepath.Join(s.punktModelDir, modelName+".json")
843
12x
        if data, err := os.ReadFile(modelPath); err == nil {
844
            trainingData = data
845
        }
846
    }
847

848
45x
    if len(trainingData) == 0 {
849
45x
        // No model available, don't cache nil (will try again next time)
850
45x
        return nil
851
45x
    }
852

853
    // Load training data into Storage
854
    storage, err := sentences.LoadTraining(trainingData)
855
    if err != nil {
856
        s.logger.Warn(context.Background(), "Failed to load Punkt model", map[string]interface{}{
857
            "language": languageCode,
858
            "error":    err.Error(),
859
        })
860
        return nil
861
    }
862

863
    // Create tokenizer with storage
864
    tokenizer := sentences.NewSentenceTokenizer(storage)
865

866
    // Cache it
867
    s.punktModels[languageCode] = tokenizer
868
    return tokenizer
869
}
870

871
// stripQuotes removes leading and trailing quote marks and brackets from a sentence
872
74x
func (s *TranslationPracticeService) stripQuotes(sentence string) string {
873
74x
    // Common quote and bracket characters (ASCII and Unicode) - both opening and closing
874
74x
    quoteChars := []string{
875
74x
        `"`, `'`, `Â`, `Â`, `"`, `'`, `'`, `"`, `"`, // ASCII and Unicode quotes
876
74x
        `(`, `)`, `[`, `]`, `{`, `}`, // ASCII brackets
877
74x
        `ï`, `ï`, `ï`, `ï`, `ï`, `ï`, // Full-width brackets
878
74x
        `â`, `â`, `â`, `â`, // Other quote marks
879
74x
        `"`, `"`, `'`, `'`, // Typographic quotes
880
74x
        `'`, `'`, // Apostrophes/quotes
881
74x
        `"`, `"`, // Double quotes
882
74x
        `Â`, `Â`, // Guillemets
883
74x
        `'`, `'`, // Single quotes
884
74x
    }
885
74x
    trimmed := strings.TrimSpace(sentence)
886
74x
    // Keep stripping until no more quotes/brackets at either end
887
74x
    changed := true
888
74x
    for changed {
889
81x
        changed = false
890
81x
        for _, char := range quoteChars {
891
2760x
            if strings.HasPrefix(trimmed, char) {
892
1x
                trimmed = strings.TrimPrefix(trimmed, char)
893
1x
                trimmed = strings.TrimSpace(trimmed)
894
1x
                changed = true
895
1x
                break // Restart check after removal
896
            }
897
2759x
            if strings.HasSuffix(trimmed, char) {
898
6x
                trimmed = strings.TrimSuffix(trimmed, char)
899
6x
                trimmed = strings.TrimSpace(trimmed)
900
6x
                changed = true
901
6x
                break // Restart check after removal
902
            }
903
        }
904
    }
905
74x
    return trimmed
906
}
907

908
// extractSentences uses Punkt tokenizer if available, otherwise falls back to regex.
909
// Preserves terminal punctuation but strips leading quotes/brackets.
910
29x
func (s *TranslationPracticeService) extractSentences(text, language string) []string {
911
29x
    // Try Punkt first if we have a model for this language
912
29x
    if punktModel := s.getPunktModel(language); punktModel != nil {
913
        tokenized := punktModel.Tokenize(text)
914
        var sentences []string
915
        for _, sent := range tokenized {
916
            sentText := strings.TrimSpace(sent.Text)
917
            // Strip leading and trailing quotes/brackets that are part of context, not the sentence
918
            sentText = s.stripQuotes(sentText)
919
            if sentText != "" {
920
                sentences = append(sentences, sentText)
921
            }
922
        }
923
        if len(sentences) > 0 {
924
            return sentences
925
        }
926
        // If Punkt returned nothing, fall through to regex
927
    }
928

929
    // Regex fallback: Extract sentences while PRESERVING terminal punctuation.
930
    // Handles common ASCII and Unicode punctuation and optional trailing quotes/brackets.
931
    //
932
    // Examples matched:
933
    //  - "Hello world." / "ÐÑÐ ÑÑÐ?" / "ÐÐ!.." / "â ÐÑÐÐÐÐ?", 'ÂÐÑÐÐÐÑ!Â', "(ÐÐ?)."
934
    //  - "äåã" / "ãããããã" (full-width punctuation)
935
    //
936
    // Strategy: find all occurrences of:
937
    //   any run of non-terminators (lazy), followed by one or more terminators, optionally followed by closing quote/bracket.
938
    //   Terminators: . ! ? â (ellipsis) plus full-width variants ãïï plus combinations like ".." or "?!"
939
    // Use raw string with Unicode characters directly for ellipsis and guillemets
940
    // Note: Build pattern carefully - terminators in negated class needs escaping
941
29x
    terminatorsCharClass := `\.\!\?âãïï` // characters for terminators (no brackets): ASCII + Unicode ellipsis + full-width
942
29x
    terminatorsGroup := `[\.\!\?âãïï]+`  // includes ellipsis â (Unicode U+2026) + full-width punctuation
943
29x
    closers := `["'\)\]Â""'ïïãã"]+?`     // quotes, brackets, guillemets (Â Unicode U+00BB) + full-width brackets
944
29x
    pattern := fmt.Sprintf(`(?s)([^%s]+?%s(?:%s)?)`, terminatorsCharClass, terminatorsGroup, closers)
945
29x
    re := regexp.MustCompile(pattern)
946
29x

947
29x
    matches := re.FindAllString(text, -1)
948
29x

949
29x
    // Fallback: if nothing matched (no terminators), return the whole text trimmed once
950
29x
    if len(matches) == 0 {
951
3x
        trimmed := strings.TrimSpace(text)
952
3x
        // Strip leading and trailing quotes/brackets that are part of context, not the sentence
953
3x
        trimmed = s.stripQuotes(trimmed)
954
3x
        if trimmed != "" {
955
1x
            return []string{trimmed}
956
1x
        }
957
2x
        return nil
958
    }
959

960
26x
    var result []string
961
26x
    for _, m := range matches {
962
71x
        sent := strings.TrimSpace(m)
963
71x
        // Strip leading and trailing quotes/brackets that are part of context, not the sentence
964
71x
        sent = s.stripQuotes(sent)
965
71x
        // Filter obvious fragments but keep meaningful short sentences (e.g., "ÐÐ.")
966
71x
        if len([]rune(sent)) >= 2 {
967
71x
            result = append(result, sent)
968
71x
        }
969
    }
970
26x
    return result
971
}
972

973
func (s *TranslationPracticeService) saveSentence(ctx context.Context, sentence *models.TranslationPracticeSentence) error {
974
    query := `
975
        INSERT INTO translation_practice_sentences
976
        (user_id, sentence_text, source_language, target_language, language_level, source_type, source_id, topic, created_at, updated_at)
977
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
978
        RETURNING id
979
    `
980

981
    err := s.db.QueryRowContext(
982
        ctx,
983
        query,
984
        sentence.UserID,
985
        sentence.SentenceText,
986
        sentence.SourceLanguage,
987
        sentence.TargetLanguage,
988
        sentence.LanguageLevel,
989
        sentence.SourceType,
990
        sentence.SourceID,
991
        sentence.Topic,
992
        sentence.CreatedAt,
993
        sentence.UpdatedAt,
994
    ).Scan(&sentence.ID)
995

996
    return err
997
}
998

999
func (s *TranslationPracticeService) getSentenceByID(ctx context.Context, sentenceID, userID uint) (*models.TranslationPracticeSentence, error) {
1000
    query := `
1001
        SELECT id, user_id, sentence_text, source_language, target_language,
1002
               language_level, source_type, source_id, topic, created_at, updated_at
1003
        FROM translation_practice_sentences
1004
        WHERE id = $1 AND user_id = $2
1005
    `
1006

1007
    var sentence models.TranslationPracticeSentence
1008
    var sourceID sql.NullInt64
1009
    var topic sql.NullString
1010

1011
    err := s.db.QueryRowContext(ctx, query, sentenceID, userID).Scan(
1012
        &sentence.ID,
1013
        &sentence.UserID,
1014
        &sentence.SentenceText,
1015
        &sentence.SourceLanguage,
1016
        &sentence.TargetLanguage,
1017
        &sentence.LanguageLevel,
1018
        &sentence.SourceType,
1019
        &sourceID,
1020
        &topic,
1021
        &sentence.CreatedAt,
1022
        &sentence.UpdatedAt,
1023
    )
1024
    if err != nil {
1025
        if errors.Is(err, sql.ErrNoRows) {
1026
            return nil, contextutils.NewAppError(
1027
                contextutils.ErrorCodeRecordNotFound,
1028
                contextutils.SeverityWarn,
1029
                "Sentence not found",
1030
                "",
1031
            )
1032
        }
1033
        return nil, err
1034
    }
1035

1036
    if sourceID.Valid {
1037
        id := uint(sourceID.Int64)
1038
        sentence.SourceID = &id
1039
    }
1040

1041
    if topic.Valid {
1042
        sentence.Topic = &topic.String
1043
    }
1044

1045
    return &sentence, nil
1046
}
1047

1048
func (s *TranslationPracticeService) saveSession(ctx context.Context, session *models.TranslationPracticeSession) error {
1049
    query := `
1050
        INSERT INTO translation_practice_sessions
1051
        (user_id, sentence_id, original_sentence, user_translation, translation_direction, ai_feedback, ai_score, created_at)
1052
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
1053
        RETURNING id
1054
    `
1055

1056
    err := s.db.QueryRowContext(
1057
        ctx,
1058
        query,
1059
        session.UserID,
1060
        session.SentenceID,
1061
        session.OriginalSentence,
1062
        session.UserTranslation,
1063
        session.TranslationDirection,
1064
        session.AIFeedback,
1065
        session.AIScore,
1066
        session.CreatedAt,
1067
    ).Scan(&session.ID)
1068

1069
    return err
1070
}
1071

1072
func (s *TranslationPracticeService) getUserByID(ctx context.Context, userID uint) (*models.User, error) {
1073
    query := `
1074
        SELECT id, username, email, preferred_language, current_level
1075
        FROM users
1076
        WHERE id = $1
1077
    `
1078

1079
    var user models.User
1080
    err := s.db.QueryRowContext(ctx, query, userID).Scan(
1081
        &user.ID,
1082
        &user.Username,
1083
        &user.Email,
1084
        &user.PreferredLanguage,
1085
        &user.CurrentLevel,
1086
    )
1087
    if err != nil {
1088
        return nil, err
1089
    }
1090

1091
    return &user, nil
1092
}
1093


			
quizapp internal services worker_service.go
22.1%
Statements
23/104
1
package services
2

3
import (
4
    "bytes"
5
    "context"
6
    "encoding/json"
7
    "fmt"
8
    "io"
9
    "net/http"
10
    "strings"
11
    "time"
12

13
    "quizapp/internal/config"
14
    "quizapp/internal/observability"
15
    "quizapp/internal/serviceinterfaces"
16
    contextutils "quizapp/internal/utils"
17

18
    "go.opentelemetry.io/otel/attribute"
19
)
20

21
// TranslationServiceInterface defines the interface for translation services
22
type TranslationServiceInterface = serviceinterfaces.TranslationService
23

24
// GoogleTranslationService handles translation requests using Google Translate API
25
type GoogleTranslationService struct {
26
    config        *config.Config
27
    httpClient    *http.Client
28
    usageStatsSvc UsageStatsServiceInterface
29
    cacheRepo     TranslationCacheRepository
30
    logger        *observability.Logger
31
}
32

33
// NewGoogleTranslationService creates a new Google translation service instance
34
3x
func NewGoogleTranslationService(config *config.Config, usageStatsSvc UsageStatsServiceInterface, cacheRepo TranslationCacheRepository, logger *observability.Logger) *GoogleTranslationService {
35
3x
    return &GoogleTranslationService{
36
3x
        config: config,
37
3x
        httpClient: &http.Client{
38
3x
            Timeout: 30 * time.Second,
39
3x
        },
40
3x
        usageStatsSvc: usageStatsSvc,
41
3x
        cacheRepo:     cacheRepo,
42
3x
        logger:        logger,
43
3x
    }
44
3x
}
45

46
// GoogleTranslateRequest represents the request format for Google Translate API
47
type GoogleTranslateRequest struct {
48
    Q      []string `json:"q"`
49
    Target string   `json:"target"`
50
    Source string   `json:"source,omitempty"`
51
    Format string   `json:"format"`
52
}
53

54
// normalizeLanguageCode converts language names to ISO codes for Google Translate API
55
func normalizeLanguageCode(lang string, languageLevels map[string]config.LanguageLevelConfig) string {
56
    // Check if it's a language name in our config
57
    for languageName, levelConfig := range languageLevels {
58
        if strings.EqualFold(languageName, lang) {
59
            return levelConfig.Code
60
        }
61
    }
62

63
    // If it's already a valid ISO code or unknown, return as-is
64
    return lang
65
}
66

67
// GoogleTranslateResponse represents the response format from Google Translate API
68
type GoogleTranslateResponse struct {
69
    Data struct {
70
        Translations []struct {
71
            TranslatedText         string `json:"translatedText"`
72
            DetectedSourceLanguage string `json:"detectedSourceLanguage"`
73
        } `json:"translations"`
74
    } `json:"data"`
75
}
76

77
// Translate translates text using the configured translation provider
78
func (s *GoogleTranslationService) Translate(ctx context.Context, req serviceinterfaces.TranslateRequest) (result *serviceinterfaces.TranslateResponse, err error) {
79
    ctx, span := observability.TraceTranslationFunction(ctx, "translate",
80
        attribute.String("translation.target_language", req.TargetLanguage),
81
        attribute.String("translation.source_language", req.SourceLanguage),
82
        attribute.Int("translation.text_length", len(req.Text)),
83
    )
84
    defer observability.FinishSpan(span, &err)
85

86
    if !s.config.Translation.Enabled {
87
        return nil, contextutils.NewAppError(contextutils.ErrorCodeServiceUnavailable, contextutils.SeverityError, "Translation service is disabled", "")
88
    }
89

90
    // Get provider config for usage stats and quota checking
91
    providerConfig, exists := s.config.Translation.Providers[s.config.Translation.DefaultProvider]
92
    if !exists {
93
        err = contextutils.NewAppError(contextutils.ErrorCodeServiceUnavailable, contextutils.SeverityError, "Translation provider not configured", "")
94
        return nil, err
95
    }
96

97
    span.SetAttributes(attribute.String("translation.provider", providerConfig.Code))
98

99
    // Generate hash for cache lookup
100
    textHash := HashText(req.Text)
101
    span.SetAttributes(attribute.String("cache.text_hash", textHash))
102

103
    // Normalize source language for consistent cache lookup
104
    normalizedSourceLang := normalizeLanguageCode(req.SourceLanguage, s.config.LanguageLevels)
105
    normalizedTargetLang := normalizeLanguageCode(req.TargetLanguage, s.config.LanguageLevels)
106

107
    // Check cache first (provider-agnostic)
108
    cachedTranslation, err := s.cacheRepo.GetCachedTranslation(ctx, textHash, normalizedSourceLang, normalizedTargetLang)
109
    if err != nil {
110
        // Log cache error but don't fail the translation request
111
        s.logger.Error(ctx, "Failed to check translation cache", err, map[string]interface{}{
112
            "text_hash":       textHash,
113
            "source_language": normalizedSourceLang,
114
            "target_language": normalizedTargetLang,
115
        })
116
    } else if cachedTranslation != nil {
117
        // Cache hit - return cached translation
118
        span.SetAttributes(
119
            attribute.Bool("cache.hit", true),
120
            attribute.String("cache.created_at", cachedTranslation.CreatedAt.Format(time.RFC3339)),
121
        )
122

123
        // Record cache hit in usage stats
124
        if err := s.usageStatsSvc.RecordUsage(ctx, providerConfig.Code, "translation_cache_hit", len(req.Text), 1); err != nil {
125
            s.logger.Error(ctx, "Failed to record translation cache hit", err)
126
        }
127

128
        return &serviceinterfaces.TranslateResponse{
129
            TranslatedText: cachedTranslation.TranslatedText,
130
            SourceLanguage: cachedTranslation.SourceLanguage,
131
            TargetLanguage: cachedTranslation.TargetLanguage,
132
        }, nil
133
    }
134

135
    // Cache miss - proceed with API call
136
    span.SetAttributes(attribute.Bool("cache.hit", false))
137

138
    // Record cache miss in usage stats
139
    if err := s.usageStatsSvc.RecordUsage(ctx, providerConfig.Code, "translation_cache_miss", 0, 1); err != nil {
140
        s.logger.Error(ctx, "Failed to record translation cache miss", err)
141
    }
142

143
    // Check quota before making the request
144
    if err := s.usageStatsSvc.CheckQuota(ctx, providerConfig.Code, "translation", len(req.Text)); err != nil {
145
        return nil, err
146
    }
147

148
    if providerConfig.APIKey == "" {
149
        err = contextutils.NewAppError(contextutils.ErrorCodeServiceUnavailable, contextutils.SeverityError, "Google Translate API key not configured", "")
150
        return nil, err
151
    }
152

153
    if req.SourceLanguage == "" || req.TargetLanguage == "" {
154
        err = contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Source and target language are required", "")
155
        return nil, err
156
    }
157

158
    if len(req.Text) == 0 {
159
        err = contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Text cannot be empty", "")
160
        return nil, err
161
    }
162

163
    if len(req.Text) > providerConfig.MaxTextLength {
164
        err = contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, fmt.Sprintf("Text cannot exceed %d characters", providerConfig.MaxTextLength), "")
165
        return nil, err
166
    }
167

168
    // Prepare request - use normalized language codes for Google Translate API
169
    requestBody := GoogleTranslateRequest{
170
        Q:      []string{req.Text},
171
        Target: normalizedTargetLang,
172
        Source: normalizedSourceLang,
173
        Format: "text",
174
    }
175

176
    jsonBody, err := json.Marshal(requestBody)
177
    if err != nil {
178
        err = contextutils.WrapError(err, "failed to marshal request")
179
        return nil, err
180
    }
181

182
    // Build URL
183
    url := fmt.Sprintf("%s%s?key=%s", providerConfig.BaseURL, providerConfig.APIEndpoint, providerConfig.APIKey)
184

185
    // Make request
186
    httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
187
    if err != nil {
188
        err = contextutils.WrapError(err, "failed to create request")
189
        return nil, err
190
    }
191

192
    httpReq.Header.Set("Content-Type", "application/json")
193

194
    resp, err := s.httpClient.Do(httpReq.WithContext(ctx))
195
    if err != nil {
196
        err = contextutils.WrapError(err, "translation request failed")
197
        return nil, err
198
    }
199
    defer func() { _ = resp.Body.Close() }()
200

201
    if resp.StatusCode != http.StatusOK {
202
        body, _ := io.ReadAll(resp.Body)
203
        err = contextutils.NewAppError(contextutils.ErrorCodeServiceUnavailable, contextutils.SeverityError,
204
            fmt.Sprintf("Google Translate API error: %d - %s", resp.StatusCode, string(body)), "")
205
        return nil, err
206
    }
207

208
    // Parse response
209
    var googleResp GoogleTranslateResponse
210
    if err := json.NewDecoder(resp.Body).Decode(&googleResp); err != nil {
211
        err = contextutils.WrapError(err, "failed to decode response")
212
        return nil, err
213
    }
214

215
    if len(googleResp.Data.Translations) == 0 {
216
        err = contextutils.NewAppError(contextutils.ErrorCodeServiceUnavailable, contextutils.SeverityError, "No translation returned from Google Translate API", "")
217
        return nil, err
218
    }
219

220
    translation := googleResp.Data.Translations[0]
221

222
    result = &serviceinterfaces.TranslateResponse{
223
        TranslatedText: translation.TranslatedText,
224
        SourceLanguage: normalizedSourceLang,
225
        TargetLanguage: normalizedTargetLang,
226
    }
227

228
    // Record usage after successful translation
229
    if err := s.usageStatsSvc.RecordUsage(ctx, providerConfig.Code, "translation", len(req.Text), 1); err != nil {
230
        // Log the error but don't fail the translation request
231
        // The translation was successful, we just couldn't record the usage
232
        // This is a non-critical error that should be logged for monitoring
233
        s.logger.Error(ctx, "Failed to record translation usage", err, map[string]interface{}{
234
            "service":    providerConfig.Code,
235
            "usage_type": "translation",
236
            "characters": len(req.Text),
237
            "requests":   1,
238
        })
239
    }
240

241
    // Save translation to cache using the normalized source language
242
    if err := s.cacheRepo.SaveTranslation(ctx, textHash, req.Text, result.SourceLanguage, req.TargetLanguage, result.TranslatedText); err != nil {
243
        // Log the error but don't fail the translation request
244
        span.SetAttributes(attribute.Bool("cache.save_error", true))
245
        s.logger.Error(ctx, "Failed to save translation to cache", err, map[string]interface{}{
246
            "text_hash":       textHash,
247
            "source_language": result.SourceLanguage,
248
            "target_language": req.TargetLanguage,
249
        })
250
    } else {
251
        span.SetAttributes(attribute.Bool("cache.saved", true))
252
    }
253

254
    return result, nil
255
}
256

257
// ValidateLanguageCode validates that a language code is properly formatted
258
10x
func (s *GoogleTranslationService) ValidateLanguageCode(langCode string) error {
259
10x
    if len(langCode) < 2 || len(langCode) > 10 {
260
4x
        return contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Language code must be 2-10 characters", "")
261
4x
    }
262

263
    // Basic validation - should be alphanumeric with possible hyphens
264
6x
    for _, char := range langCode {
265
18x
        if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '-' {
266
            return contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Invalid language code format", "")
267
        }
268
    }
269

270
6x
    return nil
271
}
272

273
// GetSupportedLanguages returns a list of supported target languages for translation
274
1x
func (s *GoogleTranslationService) GetSupportedLanguages() []string {
275
1x
    // Common languages supported by Google Translate API
276
1x
    return []string{
277
1x
        "af", "sq", "am", "ar", "hy", "az", "eu", "be", "bn", "bs", "bg", "ca", "ceb", "ny", "zh", "zh-CN", "zh-TW",
278
1x
        "co", "hr", "cs", "da", "nl", "en", "eo", "et", "tl", "fi", "fr", "fy", "gl", "ka", "de", "el", "gu", "ht",
279
1x
        "ha", "haw", "iw", "hi", "hmn", "hu", "is", "ig", "id", "ga", "it", "ja", "jw", "kn", "kk", "km", "ko", "ku",
280
1x
        "ky", "lo", "la", "lv", "lt", "lb", "mk", "mg", "ms", "ml", "mt", "mi", "mr", "mn", "my", "ne", "no", "ps",
281
1x
        "fa", "pl", "pt", "pa", "ro", "ru", "sm", "gd", "sr", "st", "sn", "sd", "si", "sk", "sl", "so", "es", "su",
282
1x
        "sw", "sv", "tg", "ta", "te", "th", "tr", "uk", "ur", "uz", "vi", "cy", "xh", "yi", "yo", "zu",
283
1x
    }
284
1x
}
285

286
// NoopTranslationService is a no-operation implementation for testing and development
287
type NoopTranslationService struct{}
288

289
// NewNoopTranslationService creates a new noop translation service instance
290
6x
func NewNoopTranslationService() *NoopTranslationService {
291
6x
    return &NoopTranslationService{}
292
6x
}
293

294
// Translate returns the original text unchanged (no-op)
295
2x
func (s *NoopTranslationService) Translate(_ context.Context, req serviceinterfaces.TranslateRequest) (*serviceinterfaces.TranslateResponse, error) {
296
2x
    return &serviceinterfaces.TranslateResponse{
297
2x
        TranslatedText: req.Text,
298
2x
        SourceLanguage: req.SourceLanguage,
299
2x
        TargetLanguage: req.TargetLanguage,
300
2x
        Confidence:     1.0,
301
2x
    }, nil
302
2x
}
303

304
// ValidateLanguageCode validates that a language code is properly formatted
305
10x
func (s *NoopTranslationService) ValidateLanguageCode(langCode string) error {
306
10x
    if len(langCode) < 2 || len(langCode) > 10 {
307
4x
        return contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Language code must be 2-10 characters", "")
308
4x
    }
309

310
    // Basic validation - should be alphanumeric with possible hyphens
311
6x
    for _, char := range langCode {
312
18x
        if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '-' {
313
            return contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Invalid language code format", "")
314
        }
315
    }
316

317
6x
    return nil
318
}
319

320
// GetSupportedLanguages returns a list of supported target languages for translation
321
1x
func (s *NoopTranslationService) GetSupportedLanguages() []string {
322
1x
    // Return a subset of common languages for testing
323
1x
    return []string{
324
1x
        "en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh",
325
1x
    }
326
1x
}
327

328
// NewTranslationService creates a translation service based on configuration
329
// For testing environments, it returns a noop service if translation is disabled
330
// For production, it returns a Google translation service if properly configured
331
4x
func NewTranslationService(config *config.Config, usageStatsSvc UsageStatsServiceInterface, cacheRepo TranslationCacheRepository, logger *observability.Logger) TranslationServiceInterface {
332
4x
    if !config.Translation.Enabled {
333
1x
        return NewNoopTranslationService()
334
1x
    }
335

336
3x
    providerConfig, exists := config.Translation.Providers[config.Translation.DefaultProvider]
337
3x
    if !exists {
338
1x
        // Fallback to noop if provider not configured
339
1x
        return NewNoopTranslationService()
340
1x
    }
341

342
2x
    switch providerConfig.Code {
343
1x
    case "google":
344
1x
        return NewGoogleTranslationService(config, usageStatsSvc, cacheRepo, logger)
345
1x
    default:
346
1x
        // Fallback to noop for unsupported providers
347
1x
        return NewNoopTranslationService()
348
    }
349
}
350


			
quizapp internal services worker_service.go
77.0%
Statements
137/178
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "fmt"
7
    "time"
8

9
    "quizapp/internal/config"
10
    "quizapp/internal/observability"
11
    contextutils "quizapp/internal/utils"
12

13
    openapi_types "github.com/oapi-codegen/runtime/types"
14
    "go.opentelemetry.io/otel/attribute"
15
)
16

17
// UsageStatsServiceInterface defines the interface for usage statistics tracking
18
type UsageStatsServiceInterface interface {
19
    // CheckQuota checks if a translation request would exceed the monthly quota
20
    CheckQuota(ctx context.Context, serviceName, usageType string, characters int) error
21
    // RecordUsage records the usage of a translation service
22
    RecordUsage(ctx context.Context, serviceName, usageType string, characters, requests int) error
23
    // GetCurrentMonthUsage returns the current month's usage for a service and type
24
    GetCurrentMonthUsage(ctx context.Context, serviceName, usageType string) (*UsageStats, error)
25
    // GetMonthlyQuota returns the monthly quota for a service
26
    GetMonthlyQuota(serviceName string) int64
27
    // GetAllUsageStats returns all usage statistics (for admin interface)
28
    GetAllUsageStats(ctx context.Context) ([]*UsageStats, error)
29
    // GetUsageStatsByService returns usage statistics for a specific service
30
    GetUsageStatsByService(ctx context.Context, serviceName string) ([]*UsageStats, error)
31
    // GetUsageStatsByMonth returns usage statistics for a specific month
32
    GetUsageStatsByMonth(ctx context.Context, year, month int) ([]*UsageStats, error)
33

34
    // AI Token usage tracking for users
35
    // RecordUserAITokenUsage records AI token usage for a specific user
36
    RecordUserAITokenUsage(ctx context.Context, userID int, apiKeyID *int, provider, model, usageType string, promptTokens, completionTokens, totalTokens, requests int) error
37
    // GetUserAITokenUsageStats returns AI token usage statistics for a specific user
38
    GetUserAITokenUsageStats(ctx context.Context, userID int, startDate, endDate time.Time) ([]*UserUsageStats, error)
39
    // GetUserAITokenUsageStatsByDay returns daily aggregated AI token usage for a user
40
    GetUserAITokenUsageStatsByDay(ctx context.Context, userID int, startDate, endDate time.Time) ([]*UserUsageStatsDaily, error)
41
    // GetUserAITokenUsageStatsByHour returns hourly aggregated AI token usage for a user on a specific day
42
    GetUserAITokenUsageStatsByHour(ctx context.Context, userID int, date time.Time) ([]*UserUsageStatsHourly, error)
43
}
44

45
// UsageStats represents usage statistics for a service in a given month
46
type UsageStats struct {
47
    ID             int       `json:"id"`
48
    ServiceName    string    `json:"service_name"`
49
    UsageType      string    `json:"usage_type"`
50
    UsageMonth     time.Time `json:"usage_month"`
51
    CharactersUsed int       `json:"characters_used"`
52
    RequestsMade   int       `json:"requests_made"`
53
    CreatedAt      time.Time `json:"created_at"`
54
    UpdatedAt      time.Time `json:"updated_at"`
55
}
56

57
// UserUsageStats represents detailed usage statistics for a user
58
type UserUsageStats struct {
59
    ID               int       `json:"id"`
60
    UserID           int       `json:"user_id"`
61
    APIKeyID         *int      `json:"api_key_id,omitempty"`
62
    UsageDate        time.Time `json:"usage_date"`
63
    UsageHour        int       `json:"usage_hour"`
64
    ServiceName      string    `json:"service_name"`
65
    Provider         string    `json:"provider"`
66
    Model            string    `json:"model"`
67
    UsageType        string    `json:"usage_type"`
68
    PromptTokens     int       `json:"prompt_tokens"`
69
    CompletionTokens int       `json:"completion_tokens"`
70
    TotalTokens      int       `json:"total_tokens"`
71
    RequestsMade     int       `json:"requests_made"`
72
    CreatedAt        time.Time `json:"created_at"`
73
    UpdatedAt        time.Time `json:"updated_at"`
74
}
75

76
// UserUsageStatsDaily represents daily aggregated usage for a user
77
type UserUsageStatsDaily struct {
78
    UsageDate             openapi_types.Date `json:"usage_date"`
79
    ServiceName           string             `json:"service_name"`
80
    Provider              string             `json:"provider"`
81
    Model                 string             `json:"model"`
82
    UsageType             string             `json:"usage_type"`
83
    TotalPromptTokens     int                `json:"total_prompt_tokens"`
84
    TotalCompletionTokens int                `json:"total_completion_tokens"`
85
    TotalTokens           int                `json:"total_tokens"`
86
    TotalRequests         int                `json:"total_requests"`
87
}
88

89
// UserUsageStatsHourly represents hourly usage for a user on a specific day
90
type UserUsageStatsHourly struct {
91
    UsageHour             int    `json:"usage_hour"`
92
    ServiceName           string `json:"service_name"`
93
    Provider              string `json:"provider"`
94
    Model                 string `json:"model"`
95
    UsageType             string `json:"usage_type"`
96
    TotalPromptTokens     int    `json:"total_prompt_tokens"`
97
    TotalCompletionTokens int    `json:"total_completion_tokens"`
98
    TotalTokens           int    `json:"total_tokens"`
99
    TotalRequests         int    `json:"total_requests"`
100
}
101

102
// UsageStatsService handles usage statistics tracking and quota management
103
type UsageStatsService struct {
104
    config *config.Config
105
    db     *sql.DB
106
    logger *observability.Logger
107
}
108

109
// NewUsageStatsService creates a new usage stats service
110
4x
func NewUsageStatsService(config *config.Config, db *sql.DB, logger *observability.Logger) *UsageStatsService {
111
4x
    return &UsageStatsService{
112
4x
        config: config,
113
4x
        db:     db,
114
4x
        logger: logger,
115
4x
    }
116
4x
}
117

118
// CheckQuota checks if a translation request would exceed the monthly quota
119
3x
func (s *UsageStatsService) CheckQuota(ctx context.Context, serviceName, usageType string, characters int) (err error) {
120
3x
    ctx, span := observability.TraceUsageStatsFunction(ctx, "check_quota",
121
3x
        attribute.String("service_name", serviceName),
122
3x
        attribute.String("usage_type", usageType),
123
3x
        attribute.Int("characters", characters),
124
3x
    )
125
3x
    defer observability.FinishSpan(span, &err)
126
3x

127
3x
    if !s.config.Translation.Quota.Enabled {
128
        return nil // Quota checking disabled
129
    }
130

131
3x
    currentUsage, err := s.GetCurrentMonthUsage(ctx, serviceName, usageType)
132
3x
    if err != nil {
133
        return contextutils.WrapError(err, "failed to get current usage")
134
    }
135

136
3x
    quota := s.GetMonthlyQuota(serviceName)
137
3x
    newTotal := currentUsage.CharactersUsed + characters
138
3x

139
3x
    if newTotal > int(quota) {
140
1x
        return contextutils.NewAppError(
141
1x
            contextutils.ErrorCodeQuotaExceeded,
142
1x
            contextutils.SeverityWarn,
143
1x
            fmt.Sprintf("Monthly quota exceeded for %s %s service. Used: %d/%d characters",
144
1x
                serviceName, usageType, newTotal, quota),
145
1x
            "",
146
1x
        )
147
1x
    }
148

149
2x
    return nil
150
}
151

152
// RecordUsage records the usage of a translation service
153
5x
func (s *UsageStatsService) RecordUsage(ctx context.Context, serviceName, usageType string, characters, requests int) (err error) {
154
5x
    ctx, span := observability.TraceUsageStatsFunction(ctx, "record_usage",
155
5x
        attribute.String("service_name", serviceName),
156
5x
        attribute.String("usage_type", usageType),
157
5x
        attribute.Int("characters", characters),
158
5x
        attribute.Int("requests", requests),
159
5x
    )
160
5x
    defer observability.FinishSpan(span, &err)
161
5x

162
5x
    currentMonth := time.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, -time.Now().UTC().Day()+1) // First day of current month
163
5x

164
5x
    query := `
165
5x
        INSERT INTO usage_stats (service_name, usage_type, usage_month, characters_used, requests_made, updated_at)
166
5x
        VALUES ($1, $2, $3, $4, $5, NOW())
167
5x
        ON CONFLICT (service_name, usage_type, usage_month)
168
5x
        DO UPDATE SET
169
5x
            characters_used = usage_stats.characters_used + $4,
170
5x
            requests_made = usage_stats.requests_made + $5,
171
5x
            updated_at = NOW()`
172
5x

173
5x
    _, err = s.db.ExecContext(ctx, query, serviceName, usageType, currentMonth, characters, requests)
174
5x
    if err != nil {
175
        return contextutils.WrapError(err, "failed to record usage")
176
    }
177

178
5x
    return nil
179
}
180

181
// RecordUserAITokenUsage records AI token usage for a specific user
182
1x
func (s *UsageStatsService) RecordUserAITokenUsage(ctx context.Context, userID int, apiKeyID *int, provider, model, usageType string, promptTokens, completionTokens, totalTokens, requests int) (err error) {
183
1x
    ctx, span := observability.TraceUsageStatsFunction(ctx, "record_user_ai_token_usage",
184
1x
        attribute.Int("user_id", userID),
185
1x
        attribute.String("provider", provider),
186
1x
        attribute.String("model", model),
187
1x
        attribute.String("usage_type", usageType),
188
1x
        attribute.Int("prompt_tokens", promptTokens),
189
1x
        attribute.Int("completion_tokens", completionTokens),
190
1x
        attribute.Int("total_tokens", totalTokens),
191
1x
        attribute.Int("requests", requests),
192
1x
    )
193
1x
    defer observability.FinishSpan(span, &err)
194
1x

195
1x
    now := time.Now()
196
1x
    usageDate := now.Truncate(24 * time.Hour) // Start of day
197
1x
    usageHour := now.Hour()
198
1x

199
1x
    query := `
200
1x
        INSERT INTO user_usage_stats (user_id, api_key_id, usage_date, usage_hour, service_name, provider, model, usage_type, prompt_tokens, completion_tokens, total_tokens, requests_made, updated_at)
201
1x
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW())
202
1x
        ON CONFLICT (user_id, api_key_id, usage_date, usage_hour, service_name, provider, model, usage_type)
203
1x
        DO UPDATE SET
204
1x
            prompt_tokens = user_usage_stats.prompt_tokens + $9,
205
1x
            completion_tokens = user_usage_stats.completion_tokens + $10,
206
1x
            total_tokens = user_usage_stats.total_tokens + $11,
207
1x
            requests_made = user_usage_stats.requests_made + $12,
208
1x
            updated_at = NOW()`
209
1x

210
1x
    _, err = s.db.ExecContext(ctx, query, userID, apiKeyID, usageDate, usageHour, "ai", provider, model, usageType, promptTokens, completionTokens, totalTokens, requests)
211
1x
    if err != nil {
212
        return contextutils.WrapError(err, "failed to record user ai token usage")
213
    }
214

215
1x
    return nil
216
}
217

218
// GetCurrentMonthUsage returns the current month's usage for a service and type
219
5x
func (s *UsageStatsService) GetCurrentMonthUsage(ctx context.Context, serviceName, usageType string) (stats *UsageStats, err error) {
220
5x
    ctx, span := observability.TraceUsageStatsFunction(ctx, "get_current_month_usage",
221
5x
        attribute.String("service_name", serviceName),
222
5x
        attribute.String("usage_type", usageType),
223
5x
    )
224
5x
    defer observability.FinishSpan(span, &err)
225
5x

226
5x
    currentMonth := time.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, -time.Now().UTC().Day()+1) // First day of current month
227
5x

228
5x
    query := `
229
5x
        SELECT id, service_name, usage_type, usage_month, characters_used, requests_made, created_at, updated_at
230
5x
        FROM usage_stats
231
5x
        WHERE service_name = $1 AND usage_type = $2 AND usage_month = $3`
232
5x

233
5x
    stats = &UsageStats{}
234
5x
    err = s.db.QueryRowContext(ctx, query, serviceName, usageType, currentMonth).Scan(
235
5x
        &stats.ID, &stats.ServiceName, &stats.UsageType, &stats.UsageMonth,
236
5x
        &stats.CharactersUsed, &stats.RequestsMade, &stats.CreatedAt, &stats.UpdatedAt,
237
5x
    )
238
5x
    if err != nil {
239
2x
        if err == sql.ErrNoRows {
240
2x
            // Return empty stats for new service/month
241
2x
            return &UsageStats{
242
2x
                ServiceName:    serviceName,
243
2x
                UsageType:      usageType,
244
2x
                UsageMonth:     currentMonth,
245
2x
                CharactersUsed: 0,
246
2x
                RequestsMade:   0,
247
2x
            }, nil
248
2x
        }
249
        return nil, contextutils.WrapError(err, "failed to get usage stats")
250
    }
251

252
3x
    return stats, nil
253
}
254

255
// GetMonthlyQuota returns the monthly quota for a service
256
3x
func (s *UsageStatsService) GetMonthlyQuota(serviceName string) int64 {
257
3x
    if !s.config.Translation.Quota.Enabled {
258
        return 0 // No quota limit when disabled
259
    }
260

261
3x
    switch serviceName {
262
2x
    case "google":
263
2x
        return s.config.Translation.Quota.GoogleMonthlyQuota
264
1x
    default:
265
1x
        return s.config.Translation.Quota.DefaultMonthlyQuota
266
    }
267
}
268

269
// GetAllUsageStats returns all usage statistics (for admin interface)
270
1x
func (s *UsageStatsService) GetAllUsageStats(ctx context.Context) (stats []*UsageStats, err error) {
271
1x
    ctx, span := observability.TraceUsageStatsFunction(ctx, "get_all_usage_stats")
272
1x
    defer observability.FinishSpan(span, &err)
273
1x

274
1x
    query := `
275
1x
        SELECT id, service_name, usage_type, usage_month, characters_used, requests_made, created_at, updated_at
276
1x
        FROM usage_stats
277
1x
        ORDER BY usage_month DESC, service_name, usage_type`
278
1x

279
1x
    rows, err := s.db.QueryContext(ctx, query)
280
1x
    if err != nil {
281
        return nil, contextutils.WrapError(err, "failed to query usage stats")
282
    }
283
1x
    defer func() {
284
1x
        if closeErr := rows.Close(); closeErr != nil {
285
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
286
        }
287
    }()
288

289
1x
    stats = []*UsageStats{}
290
1x
    for rows.Next() {
291
2x
        var stat UsageStats
292
2x
        err := rows.Scan(
293
2x
            &stat.ID, &stat.ServiceName, &stat.UsageType, &stat.UsageMonth,
294
2x
            &stat.CharactersUsed, &stat.RequestsMade, &stat.CreatedAt, &stat.UpdatedAt,
295
2x
        )
296
2x
        if err != nil {
297
            return nil, contextutils.WrapError(err, "failed to scan usage stats")
298
        }
299
2x
        stats = append(stats, &stat)
300
    }
301

302
1x
    if err := rows.Err(); err != nil {
303
        return nil, contextutils.WrapError(err, "error iterating usage stats")
304
    }
305

306
1x
    return stats, nil
307
}
308

309
// GetUsageStatsByService returns usage statistics for a specific service
310
1x
func (s *UsageStatsService) GetUsageStatsByService(ctx context.Context, serviceName string) (stats []*UsageStats, err error) {
311
1x
    ctx, span := observability.TraceUsageStatsFunction(ctx, "get_usage_stats_by_service",
312
1x
        attribute.String("service_name", serviceName),
313
1x
    )
314
1x
    defer observability.FinishSpan(span, &err)
315
1x

316
1x
    query := `
317
1x
        SELECT id, service_name, usage_type, usage_month, characters_used, requests_made, created_at, updated_at
318
1x
        FROM usage_stats
319
1x
        WHERE service_name = $1
320
1x
        ORDER BY usage_month DESC, usage_type`
321
1x

322
1x
    rows, err := s.db.QueryContext(ctx, query, serviceName)
323
1x
    if err != nil {
324
        return nil, contextutils.WrapError(err, "failed to query usage stats by service")
325
    }
326
1x
    defer func() {
327
1x
        if closeErr := rows.Close(); closeErr != nil {
328
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
329
        }
330
    }()
331

332
1x
    stats = []*UsageStats{}
333
1x
    for rows.Next() {
334
1x
        var stat UsageStats
335
1x
        err := rows.Scan(
336
1x
            &stat.ID, &stat.ServiceName, &stat.UsageType, &stat.UsageMonth,
337
1x
            &stat.CharactersUsed, &stat.RequestsMade, &stat.CreatedAt, &stat.UpdatedAt,
338
1x
        )
339
1x
        if err != nil {
340
            return nil, contextutils.WrapError(err, "failed to scan usage stats")
341
        }
342
1x
        stats = append(stats, &stat)
343
    }
344

345
1x
    if err := rows.Err(); err != nil {
346
        return nil, contextutils.WrapError(err, "error iterating usage stats")
347
    }
348

349
1x
    return stats, nil
350
}
351

352
// GetUsageStatsByMonth returns usage statistics for a specific month
353
1x
func (s *UsageStatsService) GetUsageStatsByMonth(ctx context.Context, year, month int) (stats []*UsageStats, err error) {
354
1x
    ctx, span := observability.TraceUsageStatsFunction(ctx, "get_usage_stats_by_month",
355
1x
        attribute.Int("year", year),
356
1x
        attribute.Int("month", month),
357
1x
    )
358
1x
    defer observability.FinishSpan(span, &err)
359
1x

360
1x
    // Create date for the first day of the specified month
361
1x
    targetMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
362
1x

363
1x
    query := `
364
1x
        SELECT id, service_name, usage_type, usage_month, characters_used, requests_made, created_at, updated_at
365
1x
        FROM usage_stats
366
1x
        WHERE usage_month = $1
367
1x
        ORDER BY service_name, usage_type`
368
1x

369
1x
    rows, err := s.db.QueryContext(ctx, query, targetMonth)
370
1x
    if err != nil {
371
        return nil, contextutils.WrapError(err, "failed to query usage stats by month")
372
    }
373
1x
    defer func() {
374
1x
        if closeErr := rows.Close(); closeErr != nil {
375
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
376
        }
377
    }()
378

379
1x
    stats = []*UsageStats{}
380
1x
    for rows.Next() {
381
2x
        var stat UsageStats
382
2x
        err := rows.Scan(
383
2x
            &stat.ID, &stat.ServiceName, &stat.UsageType, &stat.UsageMonth,
384
2x
            &stat.CharactersUsed, &stat.RequestsMade, &stat.CreatedAt, &stat.UpdatedAt,
385
2x
        )
386
2x
        if err != nil {
387
            return nil, contextutils.WrapError(err, "failed to scan usage stats")
388
        }
389
2x
        stats = append(stats, &stat)
390
    }
391

392
1x
    if err := rows.Err(); err != nil {
393
        return nil, contextutils.WrapError(err, "error iterating usage stats")
394
    }
395

396
1x
    return stats, nil
397
}
398

399
// GetUserAITokenUsageStats returns AI token usage statistics for a specific user
400
2x
func (s *UsageStatsService) GetUserAITokenUsageStats(ctx context.Context, userID int, startDate, endDate time.Time) (stats []*UserUsageStats, err error) {
401
2x
    ctx, span := observability.TraceUsageStatsFunction(ctx, "get_user_ai_token_usage_stats",
402
2x
        attribute.Int("user_id", userID),
403
2x
        attribute.String("start_date", startDate.Format("2006-01-02")),
404
2x
        attribute.String("end_date", endDate.Format("2006-01-02")),
405
2x
    )
406
2x
    defer observability.FinishSpan(span, &err)
407
2x

408
2x
    query := `
409
2x
        SELECT id, user_id, api_key_id, usage_date, usage_hour, service_name, provider, model, usage_type, prompt_tokens, completion_tokens, total_tokens, requests_made, created_at, updated_at
410
2x
        FROM user_usage_stats
411
2x
        WHERE user_id = $1 AND usage_date >= $2 AND usage_date <= $3
412
2x
        ORDER BY usage_date DESC, usage_hour DESC`
413
2x

414
2x
    rows, err := s.db.QueryContext(ctx, query, userID, startDate, endDate)
415
2x
    if err != nil {
416
        return nil, contextutils.WrapError(err, "failed to query user usage stats")
417
    }
418
2x
    defer func() {
419
2x
        if closeErr := rows.Close(); closeErr != nil {
420
            s.logger.Warn(ctx, "Failed to close user usage stats query", map[string]interface{}{
421
                "error": closeErr.Error(),
422
            })
423
        }
424
    }()
425

426
2x
    stats = []*UserUsageStats{}
427
2x
    for rows.Next() {
428
4x
        var stat UserUsageStats
429
4x
        err = rows.Scan(
430
4x
            &stat.ID, &stat.UserID, &stat.APIKeyID, &stat.UsageDate, &stat.UsageHour,
431
4x
            &stat.ServiceName, &stat.Provider, &stat.Model, &stat.UsageType,
432
4x
            &stat.PromptTokens, &stat.CompletionTokens, &stat.TotalTokens, &stat.RequestsMade,
433
4x
            &stat.CreatedAt, &stat.UpdatedAt,
434
4x
        )
435
4x
        if err != nil {
436
            return nil, contextutils.WrapError(err, "failed to scan user usage stats")
437
        }
438
4x
        stats = append(stats, &stat)
439
    }
440

441
2x
    if err = rows.Err(); err != nil {
442
        return nil, contextutils.WrapError(err, "error iterating user usage stats")
443
    }
444

445
2x
    return stats, nil
446
}
447

448
// GetUserAITokenUsageStatsByDay returns daily aggregated AI token usage for a user
449
1x
func (s *UsageStatsService) GetUserAITokenUsageStatsByDay(ctx context.Context, userID int, startDate, endDate time.Time) (stats []*UserUsageStatsDaily, err error) {
450
1x
    ctx, span := observability.TraceUsageStatsFunction(ctx, "get_user_ai_token_usage_stats_by_day",
451
1x
        attribute.Int("user_id", userID),
452
1x
        attribute.String("start_date", startDate.Format("2006-01-02")),
453
1x
        attribute.String("end_date", endDate.Format("2006-01-02")),
454
1x
    )
455
1x
    defer observability.FinishSpan(span, &err)
456
1x

457
1x
    query := `
458
1x
        SELECT usage_date, service_name, provider, model, usage_type,
459
1x
               SUM(prompt_tokens) as total_prompt_tokens,
460
1x
               SUM(completion_tokens) as total_completion_tokens,
461
1x
               SUM(total_tokens) as total_tokens,
462
1x
               SUM(requests_made) as total_requests
463
1x
        FROM user_usage_stats
464
1x
        WHERE user_id = $1 AND usage_date >= $2 AND usage_date <= $3
465
1x
        GROUP BY usage_date, service_name, provider, model, usage_type
466
1x
        ORDER BY usage_date DESC, service_name, provider, model, usage_type`
467
1x

468
1x
    rows, err := s.db.QueryContext(ctx, query, userID, startDate, endDate)
469
1x
    if err != nil {
470
        return nil, contextutils.WrapError(err, "failed to query user daily usage stats")
471
    }
472
1x
    defer func() {
473
1x
        if closeErr := rows.Close(); closeErr != nil {
474
            s.logger.Warn(ctx, "Failed to close user daily usage stats query", map[string]interface{}{
475
                "error": closeErr.Error(),
476
            })
477
        }
478
    }()
479

480
1x
    stats = []*UserUsageStatsDaily{}
481
1x
    for rows.Next() {
482
2x
        var stat UserUsageStatsDaily
483
2x
        var usageDate time.Time
484
2x
        err = rows.Scan(
485
2x
            &usageDate, &stat.ServiceName, &stat.Provider, &stat.Model, &stat.UsageType,
486
2x
            &stat.TotalPromptTokens, &stat.TotalCompletionTokens, &stat.TotalTokens, &stat.TotalRequests,
487
2x
        )
488
2x
        if err != nil {
489
            return nil, contextutils.WrapError(err, "failed to scan user daily usage stats")
490
        }
491
2x
        stat.UsageDate = openapi_types.Date{Time: usageDate}
492
2x
        stats = append(stats, &stat)
493
    }
494

495
1x
    if err = rows.Err(); err != nil {
496
        return nil, contextutils.WrapError(err, "error iterating user daily usage stats")
497
    }
498

499
1x
    return stats, nil
500
}
501

502
// GetUserAITokenUsageStatsByHour returns hourly aggregated AI token usage for a user on a specific day
503
1x
func (s *UsageStatsService) GetUserAITokenUsageStatsByHour(ctx context.Context, userID int, date time.Time) (stats []*UserUsageStatsHourly, err error) {
504
1x
    ctx, span := observability.TraceUsageStatsFunction(ctx, "get_user_ai_token_usage_stats_by_hour",
505
1x
        attribute.Int("user_id", userID),
506
1x
        attribute.String("date", date.Format("2006-01-02")),
507
1x
    )
508
1x
    defer observability.FinishSpan(span, &err)
509
1x

510
1x
    startOfDay := date.Truncate(24 * time.Hour)
511
1x
    endOfDay := startOfDay.Add(24 * time.Hour).Add(-time.Nanosecond)
512
1x

513
1x
    query := `
514
1x
        SELECT usage_hour, service_name, provider, model, usage_type,
515
1x
               SUM(prompt_tokens) as total_prompt_tokens,
516
1x
               SUM(completion_tokens) as total_completion_tokens,
517
1x
               SUM(total_tokens) as total_tokens,
518
1x
               SUM(requests_made) as total_requests
519
1x
        FROM user_usage_stats
520
1x
        WHERE user_id = $1 AND usage_date >= $2 AND usage_date <= $3
521
1x
        GROUP BY usage_hour, service_name, provider, model, usage_type
522
1x
        ORDER BY usage_hour, service_name, provider, model, usage_type`
523
1x

524
1x
    rows, err := s.db.QueryContext(ctx, query, userID, startOfDay, endOfDay)
525
1x
    if err != nil {
526
        return nil, contextutils.WrapError(err, "failed to query user hourly usage stats")
527
    }
528
1x
    defer func() {
529
1x
        if closeErr := rows.Close(); closeErr != nil {
530
            s.logger.Warn(ctx, "Failed to close user hourly usage stats query", map[string]interface{}{
531
                "error": closeErr.Error(),
532
            })
533
        }
534
    }()
535

536
1x
    stats = []*UserUsageStatsHourly{}
537
1x
    for rows.Next() {
538
2x
        var stat UserUsageStatsHourly
539
2x
        err = rows.Scan(
540
2x
            &stat.UsageHour, &stat.ServiceName, &stat.Provider, &stat.Model, &stat.UsageType,
541
2x
            &stat.TotalPromptTokens, &stat.TotalCompletionTokens, &stat.TotalTokens, &stat.TotalRequests,
542
2x
        )
543
2x
        if err != nil {
544
            return nil, contextutils.WrapError(err, "failed to scan user hourly usage stats")
545
        }
546
2x
        stats = append(stats, &stat)
547
    }
548

549
1x
    if err = rows.Err(); err != nil {
550
        return nil, contextutils.WrapError(err, "error iterating user hourly usage stats")
551
    }
552

553
1x
    return stats, nil
554
}
555

556
// NoopUsageStatsService is a no-operation implementation for testing and when quotas are disabled
557
type NoopUsageStatsService struct{}
558

559
// NewNoopUsageStatsService creates a new noop usage stats service
560
60x
func NewNoopUsageStatsService() *NoopUsageStatsService {
561
60x
    return &NoopUsageStatsService{}
562
60x
}
563

564
// CheckQuota always returns nil (no quota checking)
565
func (s *NoopUsageStatsService) CheckQuota(_ context.Context, _, _ string, _ int) (err error) {
566
    return nil
567
}
568

569
// RecordUsage always returns nil (no usage recording)
570
func (s *NoopUsageStatsService) RecordUsage(_ context.Context, _, _ string, _, _ int) (err error) {
571
    return nil
572
}
573

574
// GetCurrentMonthUsage returns empty stats
575
func (s *NoopUsageStatsService) GetCurrentMonthUsage(_ context.Context, _, _ string) (stats *UsageStats, err error) {
576
    return &UsageStats{
577
        ServiceName:    "",
578
        UsageType:      "",
579
        CharactersUsed: 0,
580
        RequestsMade:   0,
581
    }, nil
582
}
583

584
// GetMonthlyQuota always returns 0 (no quota limit)
585
func (s *NoopUsageStatsService) GetMonthlyQuota(_ string) int64 {
586
    return 0
587
}
588

589
// GetAllUsageStats returns all usage statistics (for admin interface)
590
func (s *NoopUsageStatsService) GetAllUsageStats(_ context.Context) ([]*UsageStats, error) {
591
    return []*UsageStats{}, nil
592
}
593

594
// GetUsageStatsByService returns usage statistics for a specific service
595
func (s *NoopUsageStatsService) GetUsageStatsByService(_ context.Context, _ string) ([]*UsageStats, error) {
596
    return []*UsageStats{}, nil
597
}
598

599
// GetUsageStatsByMonth returns usage statistics for a specific month
600
func (s *NoopUsageStatsService) GetUsageStatsByMonth(_ context.Context, _, _ int) ([]*UsageStats, error) {
601
    return []*UsageStats{}, nil
602
}
603

604
// RecordUserAITokenUsage always returns nil (no usage recording)
605
func (s *NoopUsageStatsService) RecordUserAITokenUsage(_ context.Context, _ int, _ *int, _, _, _ string, _, _, _, _ int) error {
606
    return nil
607
}
608

609
// GetUserAITokenUsageStats returns empty stats
610
func (s *NoopUsageStatsService) GetUserAITokenUsageStats(_ context.Context, _ int, _, _ time.Time) ([]*UserUsageStats, error) {
611
    return []*UserUsageStats{}, nil
612
}
613

614
// GetUserAITokenUsageStatsByDay returns empty stats
615
func (s *NoopUsageStatsService) GetUserAITokenUsageStatsByDay(_ context.Context, _ int, _, _ time.Time) ([]*UserUsageStatsDaily, error) {
616
    return []*UserUsageStatsDaily{}, nil
617
}
618

619
// GetUserAITokenUsageStatsByHour returns empty stats
620
func (s *NoopUsageStatsService) GetUserAITokenUsageStatsByHour(_ context.Context, _ int, _ time.Time) ([]*UserUsageStatsHourly, error) {
621
    return []*UserUsageStatsHourly{}, nil
622
}
623


			
quizapp internal services worker_service.go
61.8%
Statements
490/793
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "fmt"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/config"
12
    "quizapp/internal/models"
13
    "quizapp/internal/observability"
14
    contextutils "quizapp/internal/utils"
15

16
    "github.com/lib/pq"
17

18
    "go.opentelemetry.io/otel/attribute"
19
    "go.opentelemetry.io/otel/codes"
20
    "go.opentelemetry.io/otel/trace"
21
    "golang.org/x/crypto/bcrypt"
22
)
23

24
// UserServiceInterface defines the interface for user-related operations.
25
// This allows for easier mocking in tests.
26
type UserServiceInterface interface {
27
    CreateUserWithPassword(ctx context.Context, username, password, language, level string) (*models.User, error)
28
    CreateUserWithEmailAndTimezone(ctx context.Context, username, email, timezone, language, level string) (*models.User, error)
29
    GetUserByID(ctx context.Context, id int) (*models.User, error)
30
    GetUserByUsername(ctx context.Context, username string) (*models.User, error)
31
    GetUserByEmail(ctx context.Context, email string) (*models.User, error)
32
    AuthenticateUser(ctx context.Context, username, password string) (*models.User, error)
33
    UpdateUserSettings(ctx context.Context, userID int, settings *models.UserSettings) error
34
    UpdateUserProfile(ctx context.Context, userID int, username, email, timezone string) error
35
    UpdateUserPassword(ctx context.Context, userID int, newPassword string) error
36
    UpdateLastActive(ctx context.Context, userID int) error
37
    GetAllUsers(ctx context.Context) ([]models.User, error)
38
    GetUsersPaginated(ctx context.Context, page, pageSize int, search, language, level, aiProvider, aiModel, aiEnabled, active string) ([]models.User, int, error)
39
    DeleteUser(ctx context.Context, userID int) error
40
    DeleteAllUsers(ctx context.Context) error
41
    EnsureAdminUserExists(ctx context.Context, adminUsername, adminPassword string) error
42
    ResetDatabase(ctx context.Context) error
43
    ClearUserData(ctx context.Context) error
44
    ClearUserDataForUser(ctx context.Context, userID int) error
45
    GetUserAPIKey(ctx context.Context, userID int, provider string) (string, error)
46
    GetUserAPIKeyWithID(ctx context.Context, userID int, provider string) (string, *int, error)
47
    SetUserAPIKey(ctx context.Context, userID int, provider, apiKey string) error
48
    HasUserAPIKey(ctx context.Context, userID int, provider string) (bool, error)
49
    // Role management methods
50
    GetUserRoles(ctx context.Context, userID int) ([]models.Role, error)
51
    GetAllRoles(ctx context.Context) ([]models.Role, error)
52
    AssignRole(ctx context.Context, userID, roleID int) error
53
    AssignRoleByName(ctx context.Context, userID int, roleName string) error
54
    RemoveRole(ctx context.Context, userID, roleID int) error
55
    HasRole(ctx context.Context, userID int, roleName string) (bool, error)
56
    IsAdmin(ctx context.Context, userID int) (bool, error)
57
    GetDB() *sql.DB
58
    UpdateWordOfDayEmailEnabled(ctx context.Context, userID int, enabled bool) error
59
}
60

61
// UserService provides methods for user management.
62
type UserService struct {
63
    db     *sql.DB
64
    cfg    *config.Config
65
    logger *observability.Logger
66
}
67

68
// Shared query constants to eliminate duplication
69
const (
70
    // userSelectFields contains all user fields for SELECT queries
71
    userSelectFields = `id, username, email, timezone, password_hash, last_active, preferred_language, current_level, ai_provider, ai_model, ai_enabled, ai_api_key, word_of_day_email_enabled, created_at, updated_at`
72

73
    // userSelectFieldsNoPassword contains user fields excluding password_hash for GetAllUsers
74
    userSelectFieldsNoPassword = `id, username, email, timezone, last_active, preferred_language, current_level, ai_provider, ai_model, ai_enabled, ai_api_key, word_of_day_email_enabled, created_at, updated_at`
75
)
76

77
// scanUserFromRow scans a database row into a models.User struct
78
366x
func (s *UserService) scanUserFromRow(row *sql.Row) (result0 *models.User, err error) {
79
366x
    user := &models.User{}
80
366x
    err = row.Scan(
81
366x
        &user.ID, &user.Username, &user.Email, &user.Timezone, &user.PasswordHash, &user.LastActive,
82
366x
        &user.PreferredLanguage, &user.CurrentLevel, &user.AIProvider,
83
366x
        &user.AIModel, &user.AIEnabled, &user.AIAPIKey, &user.WordOfDayEmailEnabled, &user.CreatedAt, &user.UpdatedAt,
84
366x
    )
85
366x
    if err != nil {
86
19x
        return nil, err
87
19x
    }
88
347x
    return user, nil
89
}
90

91
// scanUserFromRowsNoPassword scans a database rows into a models.User struct (without password_hash)
92
16x
func (s *UserService) scanUserFromRowsNoPassword(rows *sql.Rows) (result0 *models.User, err error) {
93
16x
    user := &models.User{}
94
16x
    err = rows.Scan(
95
16x
        &user.ID, &user.Username, &user.Email, &user.Timezone, &user.LastActive,
96
16x
        &user.PreferredLanguage, &user.CurrentLevel, &user.AIProvider,
97
16x
        &user.AIModel, &user.AIEnabled, &user.AIAPIKey, &user.WordOfDayEmailEnabled, &user.CreatedAt, &user.UpdatedAt,
98
16x
    )
99
16x
    if err != nil {
100
        return nil, err
101
    }
102
16x
    return user, nil
103
}
104

105
// getUserByQuery is a shared method for getting a user by any query
106
366x
func (s *UserService) getUserByQuery(ctx context.Context, query string, args ...interface{}) (result0 *models.User, err error) {
107
366x
    row := s.db.QueryRowContext(ctx, query, args...)
108
366x
    var user *models.User
109
366x
    user, err = s.scanUserFromRow(row)
110
366x
    if err != nil {
111
19x
        if errors.Is(err, sql.ErrNoRows) {
112
19x
            return nil, nil // User not found is not an error here
113
19x
        }
114
        return nil, err
115
    }
116

117
    // Try to apply default settings, but don't fail if there's an issue
118
347x
    s.applyDefaultSettings(ctx, user)
119
347x
    return user, nil
120
}
121

122
// NewUserServiceWithLogger creates a new UserService instance with logger
123
150x
func NewUserServiceWithLogger(db *sql.DB, cfg *config.Config, logger *observability.Logger) *UserService {
124
150x
    return &UserService{
125
150x
        db:     db,
126
150x
        cfg:    cfg,
127
150x
        logger: logger,
128
150x
    }
129
150x
}
130

131
// CreateUser creates a new user with the specified username, language, and level
132
// Only used for testing purposes, should be moved to test utils if possible.
133
72x
func (s *UserService) CreateUser(ctx context.Context, username, language, level string) (result0 *models.User, err error) {
134
72x
    ctx, span := observability.TraceUserFunction(ctx, "create_user", attribute.String("user.username", username))
135
72x
    defer observability.FinishSpan(span, &err)
136
72x

137
72x
    // Validate username is not empty
138
72x
    if username == "" || len(strings.TrimSpace(username)) == 0 {
139
1x
        return nil, contextutils.WrapError(contextutils.ErrInvalidInput, "username cannot be empty")
140
1x
    }
141

142
    // default timezone to UTC for new users
143
71x
    query := `INSERT INTO users (username, preferred_language, current_level, last_active, created_at, updated_at, timezone) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`
144
71x
    now := time.Now()
145
71x
    var id int
146
71x
    err = s.db.QueryRowContext(ctx, query, username, language, level, now, now, now, "UTC").Scan(&id)
147
71x
    if err != nil {
148
1x
        return nil, err
149
1x
    }
150
70x
    var user *models.User
151
70x
    user, err = s.GetUserByID(ctx, id)
152
70x
    if err != nil {
153
        return nil, err
154
    }
155
70x
    if user == nil {
156
        return nil, contextutils.WrapError(contextutils.ErrDatabaseQuery, "user was created but could not be retrieved from database")
157
    }
158
70x
    return user, nil
159
}
160

161
// CreateUserWithEmailAndTimezone creates a new user with email and timezone
162
21x
func (s *UserService) CreateUserWithEmailAndTimezone(ctx context.Context, username, email, timezone, language, level string) (result0 *models.User, err error) {
163
21x
    ctx, span := observability.TraceUserFunction(ctx, "create_user_with_email", attribute.String("user.username", username))
164
21x
    defer observability.FinishSpan(span, &err)
165
21x

166
21x
    // Validate username is not empty
167
21x
    if username == "" || len(strings.TrimSpace(username)) == 0 {
168
        return nil, contextutils.WrapError(contextutils.ErrInvalidInput, "username cannot be empty")
169
    }
170

171
21x
    query := `INSERT INTO users (username, email, timezone, preferred_language, current_level, ai_enabled, last_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`
172
21x
    now := time.Now()
173
21x
    var id int
174
21x
    err = s.db.QueryRowContext(ctx, query, username, email, timezone, language, level, false, now, now, now).Scan(&id)
175
21x
    if err != nil {
176
        if isDuplicateKeyError(err) {
177
            return nil, contextutils.ErrRecordExists
178
        }
179
        return nil, err
180
    }
181
21x
    if err != nil {
182
        return nil, err
183
    }
184
21x
    var user *models.User
185
21x
    user, err = s.GetUserByID(ctx, id)
186
21x
    if err != nil {
187
        return nil, err
188
    }
189
21x
    if user == nil {
190
        return nil, contextutils.WrapError(contextutils.ErrDatabaseQuery, "user was created but could not be retrieved from database")
191
    }
192

193
    // Assign default "user" role to new users
194
21x
    err = s.AssignRoleByName(ctx, user.ID, "user")
195
21x
    if err != nil {
196
        // Log the error but don't fail the user creation
197
        // The user role assignment can be done manually by admin if needed
198
        s.logger.Warn(ctx, "Failed to assign default user role", map[string]interface{}{
199
            "user_id": user.ID,
200
            "error":   err.Error(),
201
        })
202
    }
203

204
21x
    return user, nil
205
}
206

207
// CreateUserWithPassword creates a new user with password authentication
208
82x
func (s *UserService) CreateUserWithPassword(ctx context.Context, username, password, language, level string) (result0 *models.User, err error) {
209
82x
    ctx, span := observability.TraceUserFunction(ctx, "create_user_with_password", attribute.String("user.username", username))
210
82x
    defer observability.FinishSpan(span, &err)
211
82x

212
82x
    // Validate username is not empty
213
82x
    if username == "" || len(strings.TrimSpace(username)) == 0 {
214
        return nil, contextutils.WrapError(contextutils.ErrInvalidInput, "username cannot be empty")
215
    }
216

217
    // Hash the password using bcrypt
218
82x
    var hashedPassword []byte
219
82x
    hashedPassword, err = bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
220
82x
    if err != nil {
221
        return nil, err
222
    }
223

224
    // default timezone to UTC for new users created with password
225
82x
    query := `INSERT INTO users (username, password_hash, preferred_language, current_level, ai_enabled, last_active, created_at, updated_at, timezone) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`
226
82x
    now := time.Now()
227
82x
    var id int
228
82x
    err = s.db.QueryRowContext(ctx, query, username, string(hashedPassword), language, level, false, now, now, now, "UTC").Scan(&id)
229
82x
    if err != nil {
230
        if isDuplicateKeyError(err) {
231
            return nil, contextutils.ErrRecordExists
232
        }
233
        return nil, err
234
    }
235
82x
    if err != nil {
236
        return nil, err
237
    }
238
82x
    user, err := s.GetUserByID(ctx, id)
239
82x
    if err != nil {
240
        return nil, err
241
    }
242
82x
    if user == nil {
243
        return nil, contextutils.WrapError(contextutils.ErrDatabaseQuery, "user was created but could not be retrieved from database")
244
    }
245

246
    // Assign default "user" role to new users
247
82x
    err = s.AssignRoleByName(ctx, user.ID, "user")
248
82x
    if err != nil {
249
        // Log the error but don't fail the user creation
250
        // The user role assignment can be done manually by admin if needed
251
        s.logger.Warn(ctx, "Failed to assign default user role", map[string]interface{}{
252
            "user_id": user.ID,
253
            "error":   err.Error(),
254
        })
255
    }
256

257
82x
    return user, nil
258
}
259

260
// AuthenticateUser verifies user credentials and returns the user if valid
261
9x
func (s *UserService) AuthenticateUser(ctx context.Context, username, password string) (result0 *models.User, err error) {
262
9x
    ctx, span := observability.TraceUserFunction(ctx, "authenticate_user", attribute.String("user.username", username))
263
9x
    defer observability.FinishSpan(span, &err)
264
9x
    // Get user by username
265
9x
    var user *models.User
266
9x
    user, err = s.GetUserByUsername(ctx, username)
267
9x
    if err != nil {
268
        return nil, err
269
    }
270
9x
    if user == nil {
271
1x
        return nil, errors.New("user not found")
272
1x
    }
273

274
    // Check if password hash exists
275
8x
    if !user.PasswordHash.Valid {
276
1x
        return nil, errors.New("user has no password set")
277
1x
    }
278

279
    // Compare provided password with stored hash
280
7x
    err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash.String), []byte(password))
281
7x
    if err != nil {
282
3x
        return nil, errors.New("invalid password")
283
3x
    }
284

285
4x
    return user, nil
286
}
287

288
// GetUserByID retrieves a user by their ID
289
347x
func (s *UserService) GetUserByID(ctx context.Context, id int) (result0 *models.User, err error) {
290
347x
    ctx, span := observability.TraceUserFunction(ctx, "get_user_by_id", attribute.Int("user.id", id))
291
347x
    defer observability.FinishSpan(span, &err)
292
347x
    query := fmt.Sprintf("SELECT %s FROM users WHERE id = $1", userSelectFields)
293
347x
    var user *models.User
294
347x
    user, err = s.getUserByQuery(ctx, query, id)
295
347x
    if err != nil {
296
        s.logger.Error(ctx, "Database error retrieving user", err, map[string]interface{}{"user_id": id})
297
        return nil, err
298
    }
299
347x
    if user == nil {
300
14x
        s.logger.Debug(ctx, "User not found in database", map[string]interface{}{"user_id": id})
301
14x
        return nil, nil
302
14x
    }
303

304
    // Load user roles
305
333x
    roles, err := s.GetUserRoles(ctx, id)
306
333x
    if err != nil {
307
        s.logger.Warn(ctx, "Failed to load user roles", map[string]interface{}{"user_id": id, "error": err.Error()})
308
        // Don't fail the entire request if roles can't be loaded
309
        user.Roles = []models.Role{}
310
    } else {
311
333x
        user.Roles = roles
312
333x
    }
313

314
333x
    return user, nil
315
}
316

317
// GetUserByUsername retrieves a user by their username
318
15x
func (s *UserService) GetUserByUsername(ctx context.Context, username string) (result0 *models.User, err error) {
319
15x
    ctx, span := observability.TraceUserFunction(ctx, "get_user_by_username", attribute.String("user.username", username))
320
15x
    defer observability.FinishSpan(span, &err)
321
15x
    query := fmt.Sprintf("SELECT %s FROM users WHERE username = $1", userSelectFields)
322
15x
    return s.getUserByQuery(ctx, query, username)
323
15x
}
324

325
// UpdateUserSettings updates user settings including AI configuration
326
10x
func (s *UserService) UpdateUserSettings(ctx context.Context, userID int, settings *models.UserSettings) (err error) {
327
10x
    ctx, span := observability.TraceUserFunction(ctx, "update_user_settings", attribute.Int("user.id", userID))
328
10x
    defer observability.FinishSpan(span, &err)
329
10x

330
10x
    // Check if user exists before updating settings
331
10x
    user, err := s.GetUserByID(ctx, userID)
332
10x
    if err != nil {
333
        return contextutils.WrapError(err, "failed to check if user exists")
334
    }
335
10x
    if user == nil {
336
1x
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
337
1x
    }
338

339
    // Start a transaction to update both user settings and API key
340
9x
    var tx *sql.Tx
341
9x
    tx, err = s.db.Begin()
342
9x
    if err != nil {
343
        return contextutils.WrapError(err, "failed to begin transaction for user settings update")
344
    }
345
9x
    defer func() {
346
9x
        if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone {
347
            s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
348
        }
349
    }()
350

351
    // Handle AI enabled logic
352
9x
    aiProvider := settings.AIProvider
353
9x
    aiModel := settings.AIModel
354
9x

355
9x
    // If AI is disabled, clear the provider and model
356
9x
    if !settings.AIEnabled {
357
2x
        aiProvider = ""
358
2x
        aiModel = ""
359
2x
    }
360

361
    // Update user settings (excluding API key which is now stored separately)
362
9x
    query := `UPDATE users SET preferred_language = $1, current_level = $2, ai_provider = $3, ai_model = $4, ai_enabled = $5, updated_at = $6 WHERE id = $7`
363
9x
    var result sql.Result
364
9x
    result, err = tx.ExecContext(ctx, query, settings.Language, settings.Level, aiProvider, aiModel, settings.AIEnabled, time.Now(), userID)
365
9x
    if err != nil {
366
        return contextutils.WrapError(err, "failed to update user settings in transaction")
367
    }
368

369
    // Check if the user was actually updated
370
9x
    rowsAffected, err := result.RowsAffected()
371
9x
    if err != nil {
372
        return contextutils.WrapError(err, "failed to get rows affected")
373
    }
374

375
9x
    if rowsAffected == 0 {
376
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "user with ID %d not found", userID)
377
    }
378

379
    // If an API key is provided and AI is enabled, save it for the specific provider
380
9x
    if settings.AIAPIKey != "" && settings.AIProvider != "" && settings.AIEnabled {
381
1x
        err = s.setUserAPIKeyTx(ctx, tx, userID, settings.AIProvider, settings.AIAPIKey)
382
1x
        if err != nil {
383
            return contextutils.WrapError(err, "failed to set user API key in transaction")
384
        }
385
    }
386

387
9x
    return tx.Commit()
388
}
389

390
// UpdateWordOfDayEmailEnabled updates the user's preference for word-of-day emails
391
2x
func (s *UserService) UpdateWordOfDayEmailEnabled(ctx context.Context, userID int, enabled bool) (err error) {
392
2x
    ctx, span := observability.TraceUserFunction(ctx, "update_word_of_day_email_enabled",
393
2x
        attribute.Int("user.id", userID),
394
2x
        attribute.Bool("word_of_day_email_enabled", enabled),
395
2x
    )
396
2x
    defer observability.FinishSpan(span, &err)
397
2x

398
2x
    // Ensure user exists
399
2x
    user, err := s.GetUserByID(ctx, userID)
400
2x
    if err != nil {
401
        return contextutils.WrapError(err, "failed to check if user exists")
402
    }
403
2x
    if user == nil {
404
        return contextutils.ErrRecordNotFound
405
    }
406

407
2x
    _, err = s.db.ExecContext(ctx, `UPDATE users SET word_of_day_email_enabled = $1, updated_at = NOW() WHERE id = $2`, enabled, userID)
408
2x
    if err != nil {
409
        return contextutils.WrapError(err, "failed to update word_of_day_email_enabled")
410
    }
411
2x
    return nil
412
}
413

414
// GetUserAPIKey retrieves the API key for a specific provider for a user
415
2x
func (s *UserService) GetUserAPIKey(ctx context.Context, userID int, provider string) (result0 string, err error) {
416
2x
    ctx, span := observability.TraceUserFunction(ctx, "get_user_api_key", attribute.Int("user.id", userID), attribute.String("user.provider", provider))
417
2x
    defer observability.FinishSpan(span, &err)
418
2x

419
2x
    // Check if user exists before getting API key
420
2x
    user, err := s.GetUserByID(ctx, userID)
421
2x
    if err != nil {
422
        return "", contextutils.WrapError(err, "failed to check if user exists")
423
    }
424
2x
    if user == nil {
425
1x
        return "", contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
426
1x
    }
427
1x
    span.SetAttributes(attribute.String("user.username", user.Username))
428
1x

429
1x
    query := `SELECT api_key FROM user_api_keys WHERE user_id = $1 AND provider = $2`
430
1x
    var apiKey string
431
1x
    err = s.db.QueryRowContext(ctx, query, userID, provider).Scan(&apiKey)
432
1x
    if err != nil {
433
1x
        if errors.Is(err, sql.ErrNoRows) {
434
1x
            return "", contextutils.WrapError(contextutils.ErrRecordNotFound, "API key for provider not found")
435
1x
        }
436
        return "", contextutils.WrapError(err, "failed to get user API key")
437
    }
438
    return apiKey, nil
439
}
440

441
// GetUserAPIKeyWithID retrieves the API key and its ID for a specific provider for a user
442
func (s *UserService) GetUserAPIKeyWithID(ctx context.Context, userID int, provider string) (apiKey string, apiKeyID *int, err error) {
443
    ctx, span := observability.TraceUserFunction(ctx, "get_user_api_key_with_id", attribute.Int("user.id", userID), attribute.String("user.provider", provider))
444
    defer observability.FinishSpan(span, &err)
445

446
    // Check if user exists before getting API key
447
    user, err := s.GetUserByID(ctx, userID)
448
    if err != nil {
449
        return "", nil, contextutils.WrapError(err, "failed to check if user exists")
450
    }
451
    if user == nil {
452
        return "", nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
453
    }
454
    span.SetAttributes(attribute.String("user.username", user.Username))
455

456
    query := `SELECT id, api_key FROM user_api_keys WHERE user_id = $1 AND provider = $2`
457
    var id int
458
    var key string
459
    err = s.db.QueryRowContext(ctx, query, userID, provider).Scan(&id, &key)
460
    if err != nil {
461
        if errors.Is(err, sql.ErrNoRows) {
462
            return "", nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "API key for provider not found")
463
        }
464
        return "", nil, contextutils.WrapError(err, "failed to get user API key with ID")
465
    }
466
    return key, &id, nil
467
}
468

469
// SetUserAPIKey sets the API key for a specific provider for a user
470
3x
func (s *UserService) SetUserAPIKey(ctx context.Context, userID int, provider, apiKey string) (err error) {
471
3x
    ctx, span := observability.TraceUserFunction(ctx, "set_user_api_key", attribute.Int("user.id", userID), attribute.String("user.provider", provider))
472
3x
    defer observability.FinishSpan(span, &err)
473
3x

474
3x
    // Check if user exists before setting API key
475
3x
    user, err := s.GetUserByID(ctx, userID)
476
3x
    if err != nil {
477
        return contextutils.WrapError(err, "failed to check if user exists")
478
    }
479
3x
    if user == nil {
480
1x
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
481
1x
    }
482
2x
    span.SetAttributes(attribute.String("user.username", user.Username))
483
2x

484
2x
    var tx *sql.Tx
485
2x
    tx, err = s.db.Begin()
486
2x
    if err != nil {
487
        return contextutils.WrapError(err, "failed to begin transaction for API key update")
488
    }
489
2x
    defer func() {
490
2x
        if err != nil {
491
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
492
                s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
493
            }
494
        }
495
    }()
496

497
2x
    err = s.setUserAPIKeyTx(ctx, tx, userID, provider, apiKey)
498
2x
    if err != nil {
499
        return contextutils.WrapError(err, "failed to set user API key in transaction")
500
    }
501

502
2x
    commitErr := tx.Commit()
503
2x
    if commitErr != nil {
504
        return contextutils.WrapError(commitErr, "failed to commit API key transaction")
505
    }
506

507
    // Clear the error so defer doesn't try to rollback
508
2x
    err = nil
509
2x
    return nil
510
}
511

512
// setUserAPIKeyTx sets the API key for a specific provider within a transaction
513
3x
func (s *UserService) setUserAPIKeyTx(ctx context.Context, tx *sql.Tx, userID int, provider, apiKey string) error {
514
3x
    query := `INSERT INTO user_api_keys (user_id, provider, api_key, updated_at)
515
3x
              VALUES ($1, $2, $3, $4)
516
3x
              ON CONFLICT (user_id, provider)
517
3x
              DO UPDATE SET api_key = $3, updated_at = $4`
518
3x
    _, err := tx.ExecContext(ctx, query, userID, provider, apiKey, time.Now())
519
3x
    return contextutils.WrapError(err, "failed to execute API key transaction")
520
3x
}
521

522
// HasUserAPIKey checks if a user has an API key for a specific provider
523
1x
func (s *UserService) HasUserAPIKey(ctx context.Context, userID int, provider string) (result0 bool, err error) {
524
1x
    ctx, span := observability.TraceUserFunction(ctx, "has_user_api_key", attribute.Int("user.id", userID), attribute.String("user.provider", provider))
525
1x
    defer observability.FinishSpan(span, &err)
526
1x
    var apiKey string
527
1x
    user, err := s.GetUserByID(ctx, userID)
528
1x
    if err != nil {
529
        return false, contextutils.WrapError(err, "failed to check if user exists")
530
    }
531
1x
    if user == nil {
532
1x
        return false, contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
533
1x
    }
534
    span.SetAttributes(attribute.String("user.username", user.Username))
535
    apiKey, err = s.GetUserAPIKey(ctx, userID, provider)
536
    if err != nil {
537
        // If the error is "not found" and it's specifically about the API key not existing (not the user),
538
        // then it means no API key exists, which is not an error
539
        if errors.Is(err, contextutils.ErrRecordNotFound) {
540
            // Check if the error message indicates it's about the API key, not the user
541
            if strings.Contains(err.Error(), "API key for provider not found") {
542
                return false, nil
543
            }
544
            // If it's about the user not found, return the error
545
            return false, err
546
        }
547
        return false, contextutils.WrapError(err, "failed to check if user has API key")
548
    }
549
    return apiKey != "", nil
550
}
551

552
// UpdateLastActive updates the user's last activity timestamp
553
1x
func (s *UserService) UpdateLastActive(ctx context.Context, userID int) (err error) {
554
1x
    ctx, span := observability.TraceUserFunction(ctx, "update_last_active", attribute.Int("user.id", userID))
555
1x
    defer observability.FinishSpan(span, &err)
556
1x

557
1x
    user, err := s.GetUserByID(ctx, userID)
558
1x
    if err != nil {
559
        return contextutils.WrapError(err, "failed to check if user exists")
560
    }
561
1x
    if user == nil {
562
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
563
    }
564
1x
    span.SetAttributes(attribute.String("user.username", user.Username))
565
1x

566
1x
    span.SetAttributes(attribute.String("user.username", user.Username))
567
1x
    query := `UPDATE users SET last_active = $1 WHERE id = $2`
568
1x
    var result sql.Result
569
1x
    result, err = s.db.ExecContext(ctx, query, time.Now(), userID)
570
1x
    if err != nil {
571
        return contextutils.WrapError(err, "failed to update user last active timestamp")
572
    }
573

574
    // Check if the user was actually updated
575
1x
    rowsAffected, err := result.RowsAffected()
576
1x
    if err != nil {
577
        return contextutils.WrapError(err, "failed to get rows affected")
578
    }
579

580
1x
    if rowsAffected == 0 {
581
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "user with ID %d not found", userID)
582
    }
583

584
1x
    return nil
585
}
586

587
// GetAllUsers retrieves all users from the database
588
4x
func (s *UserService) GetAllUsers(ctx context.Context) (result0 []models.User, err error) {
589
4x
    ctx, span := observability.TraceUserFunction(ctx, "get_all_users")
590
4x
    defer observability.FinishSpan(span, &err)
591
4x
    query := fmt.Sprintf("SELECT %s FROM users", userSelectFieldsNoPassword)
592
4x
    var rows *sql.Rows
593
4x
    rows, err = s.db.QueryContext(ctx, query)
594
4x
    if err != nil {
595
        return nil, contextutils.WrapError(err, "failed to query all users")
596
    }
597
4x
    defer func() {
598
4x
        if err = rows.Close(); err != nil {
599
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": err.Error()})
600
        }
601
    }()
602

603
4x
    var users []models.User
604
4x
    for rows.Next() {
605
16x
        user, err := s.scanUserFromRowsNoPassword(rows)
606
16x
        if err != nil {
607
            return nil, contextutils.WrapError(err, "failed to scan user from rows")
608
        }
609

610
        // Load user roles
611
16x
        roles, err := s.GetUserRoles(ctx, user.ID)
612
16x
        if err != nil {
613
            s.logger.Warn(ctx, "Failed to load user roles", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
614
            // Don't fail the entire request if roles can't be loaded
615
            user.Roles = []models.Role{}
616
        } else {
617
16x
            user.Roles = roles
618
16x
        }
619

620
16x
        users = append(users, *user)
621
    }
622

623
4x
    return users, nil
624
}
625

626
// GetUsersPaginated retrieves paginated users with filtering and search
627
func (s *UserService) GetUsersPaginated(ctx context.Context, page, pageSize int, search, language, level, aiProvider, aiModel, aiEnabled, active string) (result0 []models.User, result1 int, err error) {
628
    ctx, span := observability.TraceUserFunction(ctx, "get_users_paginated")
629
    defer observability.FinishSpan(span, &err)
630

631
    // Build WHERE clause and args
632
    var conditions []string
633
    var args []interface{}
634
    argIndex := 1
635

636
    // Search filter
637
    if search != "" {
638
        conditions = append(conditions, fmt.Sprintf("(username ILIKE $%d OR email ILIKE $%d)", argIndex, argIndex))
639
        args = append(args, "%"+search+"%")
640
        argIndex++
641
    }
642

643
    // Language filter
644
    if language != "" {
645
        conditions = append(conditions, fmt.Sprintf("preferred_language = $%d", argIndex))
646
        args = append(args, language)
647
        argIndex++
648
    }
649

650
    // Level filter
651
    if level != "" {
652
        conditions = append(conditions, fmt.Sprintf("current_level = $%d", argIndex))
653
        args = append(args, level)
654
        argIndex++
655
    }
656

657
    // AI Provider filter
658
    if aiProvider != "" {
659
        conditions = append(conditions, fmt.Sprintf("ai_provider = $%d", argIndex))
660
        args = append(args, aiProvider)
661
        argIndex++
662
    }
663

664
    // AI Model filter
665
    if aiModel != "" {
666
        conditions = append(conditions, fmt.Sprintf("ai_model = $%d", argIndex))
667
        args = append(args, aiModel)
668
        argIndex++
669
    }
670

671
    // AI Enabled filter
672
    if aiEnabled != "" {
673
        enabled := aiEnabled == "true"
674
        conditions = append(conditions, fmt.Sprintf("ai_enabled = $%d", argIndex))
675
        args = append(args, enabled)
676
        argIndex++
677
    }
678

679
    // Active filter (based on last_active within 7 days)
680
    if active != "" {
681
        activeThreshold := time.Now().AddDate(0, 0, -7)
682
        switch active {
683
        case "true":
684
            conditions = append(conditions, fmt.Sprintf("last_active >= $%d", argIndex))
685
            args = append(args, activeThreshold)
686
        case "false":
687
            conditions = append(conditions, fmt.Sprintf("(last_active < $%d OR last_active IS NULL)", argIndex))
688
            args = append(args, activeThreshold)
689
        }
690
        argIndex++
691
    }
692

693
    // Build WHERE clause
694
    whereClause := ""
695
    if len(conditions) > 0 {
696
        whereClause = "WHERE " + strings.Join(conditions, " AND ")
697
    }
698

699
    // Get total count
700
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM users %s", whereClause)
701
    var total int
702
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total)
703
    if err != nil {
704
        return nil, 0, contextutils.WrapError(err, "failed to count users")
705
    }
706

707
    // Get paginated results
708
    offset := (page - 1) * pageSize
709
    query := fmt.Sprintf("SELECT %s FROM users %s ORDER BY username LIMIT $%d OFFSET $%d",
710
        userSelectFieldsNoPassword, whereClause, argIndex, argIndex+1)
711
    args = append(args, pageSize, offset)
712

713
    rows, err := s.db.QueryContext(ctx, query, args...)
714
    if err != nil {
715
        return nil, 0, contextutils.WrapError(err, "failed to query paginated users")
716
    }
717
    defer func() {
718
        if closeErr := rows.Close(); closeErr != nil {
719
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
720
        }
721
    }()
722

723
    var users []models.User
724
    for rows.Next() {
725
        user, err := s.scanUserFromRowsNoPassword(rows)
726
        if err != nil {
727
            return nil, 0, contextutils.WrapError(err, "failed to scan user from rows")
728
        }
729

730
        // Load user roles
731
        roles, err := s.GetUserRoles(ctx, user.ID)
732
        if err != nil {
733
            s.logger.Warn(ctx, "Failed to load user roles", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
734
            // Don't fail the entire request if roles can't be loaded
735
            user.Roles = []models.Role{}
736
        } else {
737
            user.Roles = roles
738
        }
739

740
        users = append(users, *user)
741
    }
742

743
    return users, total, nil
744
}
745

746
// GetUserByEmail retrieves a user by their email address
747
4x
func (s *UserService) GetUserByEmail(ctx context.Context, email string) (result0 *models.User, err error) {
748
4x
    ctx, span := observability.TraceUserFunction(ctx, "get_user_by_email", attribute.String("user.email", email))
749
4x
    defer observability.FinishSpan(span, &err)
750
4x
    query := fmt.Sprintf("SELECT %s FROM users WHERE email = $1", userSelectFields)
751
4x
    return s.getUserByQuery(ctx, query, email)
752
4x
}
753

754
// UpdateUserProfile updates user profile information (username, email, timezone)
755
1x
func (s *UserService) UpdateUserProfile(ctx context.Context, userID int, username, email, timezone string) (err error) {
756
1x
    ctx, span := observability.TraceUserFunction(ctx, "update_user_profile", attribute.Int("user.id", userID))
757
1x
    defer observability.FinishSpan(span, &err)
758
1x
    query := `UPDATE users SET username = $1, email = $2, timezone = $3, updated_at = $4 WHERE id = $5`
759
1x
    var result sql.Result
760
1x
    result, err = s.db.ExecContext(ctx, query, username, email, timezone, time.Now(), userID)
761
1x
    if err != nil {
762
        return contextutils.WrapError(err, "failed to update user profile")
763
    }
764

765
    // Check if the user was actually updated
766
1x
    rowsAffected, err := result.RowsAffected()
767
1x
    if err != nil {
768
        return contextutils.WrapError(err, "failed to get rows affected")
769
    }
770

771
1x
    if rowsAffected == 0 {
772
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "user with ID %d not found", userID)
773
    }
774

775
1x
    return nil
776
}
777

778
// UpdateUserPassword updates a user's password
779
5x
func (s *UserService) UpdateUserPassword(ctx context.Context, userID int, newPassword string) (err error) {
780
5x
    ctx, span := observability.TraceUserFunction(ctx, "update_user_password", attribute.Int("user.id", userID))
781
5x
    defer observability.FinishSpan(span, &err)
782
5x

783
5x
    // Validate password is not empty
784
5x
    if newPassword == "" {
785
1x
        return contextutils.ErrorWithContextf("password cannot be empty")
786
1x
    }
787

788
    // Check if user exists first
789
4x
    user, err := s.GetUserByID(ctx, userID)
790
4x
    if err != nil {
791
        return contextutils.WrapError(err, "failed to check if user exists")
792
    }
793
4x
    if user == nil {
794
1x
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
795
1x
    }
796

797
    // Hash the new password using bcrypt
798
3x
    var hashedPassword []byte
799
3x
    hashedPassword, err = bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
800
3x
    if err != nil {
801
        return contextutils.WrapError(err, "failed to hash password")
802
    }
803

804
3x
    query := `UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3`
805
3x
    result, err := s.db.ExecContext(ctx, query, string(hashedPassword), time.Now(), userID)
806
3x
    if err != nil {
807
        return contextutils.WrapError(err, "failed to update user password")
808
    }
809

810
    // Check if any rows were affected
811
3x
    rowsAffected, err := result.RowsAffected()
812
3x
    if err != nil {
813
        return contextutils.WrapError(err, "failed to get rows affected")
814
    }
815

816
3x
    if rowsAffected == 0 {
817
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
818
    }
819

820
3x
    s.logger.Info(ctx, "Password updated successfully", map[string]interface{}{"user_id": userID, "username": user.Username})
821
3x
    return nil
822
}
823

824
// DeleteUser removes a user and their associated data
825
3x
func (s *UserService) DeleteUser(ctx context.Context, userID int) (err error) {
826
3x
    ctx, span := observability.TraceUserFunction(ctx, "delete_user", attribute.Int("user.id", userID))
827
3x
    defer observability.FinishSpan(span, &err)
828
3x

829
3x
    // Check if user exists before deleting
830
3x
    user, err := s.GetUserByID(ctx, userID)
831
3x
    if err != nil {
832
        return contextutils.WrapError(err, "failed to check if user exists")
833
    }
834
3x
    if user == nil {
835
1x
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
836
1x
    }
837

838
    // Best-effort cleanup of dependent rows for tables that may not have ON DELETE CASCADE in some environments
839
    // This keeps tests deterministic and avoids orphaned data
840
    // TODO: This is a hack to make the tests deterministic. We should use ON DELETE CASCADE instead.
841
2x
    cleanupQueries := []string{
842
2x
        `DELETE FROM question_reports WHERE reported_by_user_id = $1`,
843
2x
        `DELETE FROM user_api_keys WHERE user_id = $1`,
844
2x
        `DELETE FROM user_roles WHERE user_id = $1`,
845
2x
        `DELETE FROM user_learning_preferences WHERE user_id = $1`,
846
2x
        `DELETE FROM question_priority_scores WHERE user_id = $1`,
847
2x
        `DELETE FROM user_question_metadata WHERE user_id = $1`,
848
2x
        `DELETE FROM user_responses WHERE user_id = $1`,
849
2x
        `DELETE FROM user_questions WHERE user_id = $1`,
850
2x
    }
851
2x
    for _, q := range cleanupQueries {
852
16x
        if _, err := s.db.ExecContext(ctx, q, userID); err != nil {
853
            s.logger.Warn(ctx, "Non-fatal cleanup failure during user delete", map[string]interface{}{"error": err.Error(), "query": q, "user_id": userID})
854
        }
855
    }
856

857
    // Delete the user
858
2x
    query := `DELETE FROM users WHERE id = $1`
859
2x
    result, err := s.db.ExecContext(ctx, query, userID)
860
2x
    if err != nil {
861
        return contextutils.WrapError(err, "failed to delete user")
862
    }
863

864
2x
    rowsAffected, err := result.RowsAffected()
865
2x
    if err != nil {
866
        return contextutils.WrapError(err, "failed to get rows affected")
867
    }
868

869
2x
    if rowsAffected == 0 {
870
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
871
    }
872

873
2x
    s.logger.Info(ctx, "User %d deleted successfully", map[string]interface{}{"user_id": userID})
874
2x
    return nil
875
}
876

877
// DeleteAllUsers removes all users from the database
878
2x
func (s *UserService) DeleteAllUsers(ctx context.Context) (err error) {
879
2x
    ctx, span := observability.TraceUserFunction(ctx, "delete_all_users")
880
2x
    defer observability.FinishSpan(span, &err)
881
2x
    var tx *sql.Tx
882
2x
    tx, err = s.db.Begin()
883
2x
    if err != nil {
884
        return contextutils.WrapError(err, "failed to begin transaction for delete all users")
885
    }
886
2x
    defer func() {
887
2x
        if err != nil {
888
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
889
                s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
890
            }
891
        }
892
    }()
893

894
    // Whitelist of valid table names to prevent SQL injection
895
2x
    validTables := map[string]bool{
896
2x
        "user_responses":      true,
897
2x
        "performance_metrics": true,
898
2x
        "users":               true,
899
2x
    }
900
2x

901
2x
    // Delete all data in the correct order (to respect foreign key constraints)
902
2x
    tables := []string{
903
2x
        "user_responses",
904
2x
        "performance_metrics",
905
2x
        "users",
906
2x
    }
907
2x

908
2x
    for _, table := range tables {
909
6x
        // Validate table name against whitelist
910
6x
        if !validTables[table] {
911
            return contextutils.ErrorWithContextf("invalid table name: %s", table)
912
        }
913

914
        // Use parameterized query with validated table name
915
6x
        query := fmt.Sprintf("DELETE FROM %s", table)
916
6x
        if _, err := tx.ExecContext(ctx, query); err != nil {
917
            return contextutils.WrapErrorf(err, "failed to delete from table %s", table)
918
        }
919
        // Reset sequence for PostgreSQL
920
6x
        sequenceQuery := fmt.Sprintf("ALTER SEQUENCE %s_id_seq RESTART WITH 1", table)
921
6x
        if _, err := tx.ExecContext(ctx, sequenceQuery); err != nil {
922
            // This might fail if the table doesn't have a sequence, so we log but don't fail
923
            s.logger.Warn(ctx, "Note: Could not reset sequence for %s (this is normal for some tables)", map[string]interface{}{"table": table})
924
        }
925
    }
926

927
2x
    return contextutils.WrapError(tx.Commit(), "failed to commit delete all users transaction")
928
}
929

930
// EnsureAdminUserExists creates the admin user if it doesn't exist
931
5x
func (s *UserService) EnsureAdminUserExists(ctx context.Context, adminUsername, adminPassword string) (err error) {
932
5x
    ctx, span := observability.TraceUserFunction(ctx, "ensure_admin_user_exists", attribute.String("admin.username", adminUsername))
933
5x
    defer observability.FinishSpan(span, &err)
934
5x

935
5x
    // Validate input parameters
936
5x
    if adminUsername == "" {
937
1x
        return contextutils.ErrorWithContextf("admin username cannot be empty")
938
1x
    }
939

940
4x
    if adminPassword == "" {
941
1x
        return contextutils.ErrorWithContextf("admin password cannot be empty")
942
1x
    }
943
    // Check if admin user already exists
944
3x
    var existingUser *models.User
945
3x
    existingUser, err = s.GetUserByUsername(ctx, adminUsername)
946
3x
    if err != nil {
947
        return contextutils.WrapError(err, "failed to check if admin user exists")
948
    }
949

950
3x
    if existingUser != nil {
951
1x
        // User exists, check if password needs to be updated
952
1x
        if existingUser.PasswordHash.Valid {
953
1x
            // User has a password, test if it matches current admin password
954
1x
            err = bcrypt.CompareHashAndPassword([]byte(existingUser.PasswordHash.String), []byte(adminPassword))
955
1x
            if err == nil {
956
                // Password matches, ensure AI settings are configured
957
                err = s.ensureAdminAISettings(ctx, existingUser.ID)
958
                if err != nil {
959
                    s.logger.Warn(ctx, "Warning: Failed to set AI settings for existing admin user", map[string]interface{}{"error": err.Error()})
960
                }
961

962
                // Ensure admin user has email and timezone if not set
963
                if !existingUser.Email.Valid || !existingUser.Timezone.Valid {
964
                    err = s.ensureAdminProfile(ctx, existingUser.ID)
965
                    if err != nil {
966
                        s.logger.Warn(ctx, "Warning: Failed to update admin profile", map[string]interface{}{"error": err.Error()})
967
                    }
968
                }
969

970
                // Ensure admin user has admin role
971
                isAdmin, err := s.IsAdmin(ctx, existingUser.ID)
972
                if err != nil {
973
                    s.logger.Warn(ctx, "Warning: Failed to check admin role for existing admin user", map[string]interface{}{"error": err.Error()})
974
                } else if !isAdmin {
975
                    err = s.AssignRoleByName(ctx, existingUser.ID, "admin")
976
                    if err != nil {
977
                        s.logger.Warn(ctx, "Warning: Failed to assign admin role to existing admin user", map[string]interface{}{"error": err.Error()})
978
                    }
979
                }
980

981
                s.logger.Info(ctx, "Admin user already exists with correct password", map[string]interface{}{"username": adminUsername})
982
                return nil
983
            }
984
        }
985

986
        // Update password
987
1x
        hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
988
1x
        if err != nil {
989
            return contextutils.WrapError(err, "failed to hash admin password")
990
        }
991

992
1x
        query := `UPDATE users SET password_hash = $1, updated_at = $2 WHERE username = $3`
993
1x
        _, err = s.db.ExecContext(ctx, query, string(hashedPassword), time.Now(), adminUsername)
994
1x
        if err != nil {
995
            return contextutils.WrapError(err, "failed to update admin user password")
996
        }
997

998
        // Ensure AI settings are configured
999
1x
        err = s.ensureAdminAISettings(ctx, existingUser.ID)
1000
1x
        if err != nil {
1001
            s.logger.Warn(ctx, "Warning: Failed to set AI settings for existing admin user", map[string]interface{}{"error": err.Error()})
1002
        }
1003

1004
        // Ensure admin user has email and timezone if not set
1005
1x
        if !existingUser.Email.Valid || !existingUser.Timezone.Valid {
1006
            err = s.ensureAdminProfile(ctx, existingUser.ID)
1007
            if err != nil {
1008
                s.logger.Warn(ctx, "Warning: Failed to update admin profile", map[string]interface{}{"error": err.Error()})
1009
            }
1010
        }
1011

1012
        // Ensure admin user has admin role
1013
1x
        isAdmin, err := s.IsAdmin(ctx, existingUser.ID)
1014
1x
        if err != nil {
1015
            s.logger.Warn(ctx, "Warning: Failed to check admin role for existing admin user", map[string]interface{}{"error": err.Error()})
1016
        } else if !isAdmin {
1017
            err = s.AssignRoleByName(ctx, existingUser.ID, "admin")
1018
            if err != nil {
1019
                s.logger.Warn(ctx, "Warning: Failed to assign admin role to existing admin user", map[string]interface{}{"error": err.Error()})
1020
            }
1021
        }
1022

1023
1x
        s.logger.Info(ctx, "Updated password for admin user", map[string]interface{}{"username": adminUsername})
1024
1x
        return nil
1025
    }
1026

1027
    // Create new admin user with email and timezone
1028
2x
    user, err := s.CreateUserWithEmailAndTimezone(ctx, adminUsername, "admin@example.com", "America/New_York", "italian", "A1")
1029
2x
    if err != nil {
1030
        return contextutils.WrapError(err, "failed to create admin user")
1031
    }
1032

1033
    // Set password for the admin user
1034
2x
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
1035
2x
    if err != nil {
1036
        return contextutils.WrapError(err, "failed to hash new admin password")
1037
    }
1038

1039
2x
    query := `UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3`
1040
2x
    _, err = s.db.ExecContext(ctx, query, string(hashedPassword), time.Now(), user.ID)
1041
2x
    if err != nil {
1042
        return contextutils.WrapError(err, "failed to set password for new admin user")
1043
    }
1044

1045
    // Set up AI settings for the admin user
1046
2x
    err = s.ensureAdminAISettings(ctx, user.ID)
1047
2x
    if err != nil {
1048
        s.logger.Warn(ctx, "Warning: Failed to set AI settings for new admin user", map[string]interface{}{"error": err.Error()})
1049
    }
1050

1051
    // Assign admin role to the admin user
1052
2x
    err = s.AssignRoleByName(ctx, user.ID, "admin")
1053
2x
    if err != nil {
1054
        s.logger.Warn(ctx, "Warning: Failed to assign admin role to new admin user", map[string]interface{}{"error": err.Error()})
1055
    }
1056

1057
2x
    s.logger.Info(ctx, "Created admin user", map[string]interface{}{"username": adminUsername})
1058
2x
    return nil
1059
}
1060

1061
// ensureAdminAISettings ensures the admin user has AI settings configured
1062
// Only sets default values if the user doesn't already have AI settings configured
1063
3x
func (s *UserService) ensureAdminAISettings(ctx context.Context, userID int) (err error) {
1064
3x
    ctx, span := observability.TraceUserFunction(ctx, "ensure_admin_ai_settings", attribute.Int("user.id", userID))
1065
3x
    defer observability.FinishSpan(span, &err)
1066
3x
    var user *models.User
1067
3x
    user, err = s.GetUserByID(ctx, userID)
1068
3x
    if err != nil {
1069
        return err
1070
    }
1071
3x
    if user == nil {
1072
        return errors.New("admin user not found")
1073
    }
1074

1075
    // If user already has AI provider configured, don't override their settings
1076
3x
    if user.AIProvider.Valid && user.AIProvider.String != "" {
1077
1x
        s.logger.Info(ctx, "User ID already has AI settings configured, preserving existing settings", map[string]interface{}{"user_id": userID, "provider": user.AIProvider.String})
1078
1x
        return nil
1079
1x
    }
1080

1081
    // Set default AI settings with a default API key
1082
2x
    settings := &models.UserSettings{
1083
2x
        AIProvider: "ollama",
1084
2x
        AIModel:    "llama4:latest",
1085
2x
        AIAPIKey:   "not_needed", // Default API key
1086
2x
    }
1087
2x

1088
2x
    // Only update AI settings, preserve other user settings
1089
2x
    query := `UPDATE users SET ai_provider = $1, ai_model = $2, ai_api_key = $3, updated_at = $4 WHERE id = $5`
1090
2x
    _, err = s.db.ExecContext(ctx, query, settings.AIProvider, settings.AIModel, settings.AIAPIKey, time.Now(), userID)
1091
2x
    if err != nil {
1092
        return contextutils.WrapError(err, "failed to update user AI settings")
1093
    }
1094

1095
    // Save the API key to the user_api_keys table
1096
2x
    err = s.SetUserAPIKey(ctx, userID, settings.AIProvider, settings.AIAPIKey)
1097
2x
    if err != nil {
1098
        s.logger.Warn(ctx, "Warning: Failed to save API key for user %d", map[string]interface{}{"user_id": userID, "error": err.Error()})
1099
    }
1100

1101
2x
    s.logger.Info(ctx, "Set default AI settings for user", map[string]interface{}{"user_id": userID, "provider": settings.AIProvider, "model": settings.AIModel})
1102
2x
    return nil
1103
}
1104

1105
// ensureAdminProfile ensures the admin user has email and timezone set
1106
func (s *UserService) ensureAdminProfile(ctx context.Context, userID int) (err error) {
1107
    ctx, span := observability.TraceUserFunction(ctx, "ensure_admin_profile", attribute.Int("user.id", userID))
1108
    defer observability.FinishSpan(span, &err)
1109
    query := `UPDATE users SET email = $1, timezone = $2, updated_at = $3 WHERE id = $4 AND (email IS NULL OR timezone IS NULL)`
1110
    _, err = s.db.ExecContext(ctx, query, "admin@example.com", "America/New_York", time.Now(), userID)
1111
    if err != nil {
1112
        return contextutils.WrapError(err, "failed to update admin profile")
1113
    }
1114

1115
    s.logger.Info(ctx, "Updated admin user profile with default email and timezone", map[string]interface{}{"user_id": userID})
1116
    return nil
1117
}
1118

1119
// ResetDatabase completely resets the database to an empty state
1120
func (s *UserService) ResetDatabase(ctx context.Context) (err error) {
1121
    ctx, span := observability.TraceUserFunction(ctx, "reset_database")
1122
    defer observability.FinishSpan(span, &err)
1123
    var tx *sql.Tx
1124
    tx, err = s.db.Begin()
1125
    if err != nil {
1126
        return contextutils.WrapError(err, "failed to begin transaction for database reset")
1127
    }
1128
    defer func() {
1129
        if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone {
1130
            s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
1131
        }
1132
    }()
1133

1134
    // Whitelist of valid table names to prevent SQL injection
1135
    validTables := map[string]bool{
1136
        "user_responses":      true,
1137
        "performance_metrics": true,
1138
        "questions":           true,
1139
        "users":               true,
1140
    }
1141

1142
    // Delete all data in the correct order (to respect foreign key constraints)
1143
    tables := []string{
1144
        "user_responses",
1145
        "performance_metrics",
1146
        "questions",
1147
        "users",
1148
    }
1149

1150
    for _, table := range tables {
1151
        // Validate table name against whitelist
1152
        if !validTables[table] {
1153
            return contextutils.ErrorWithContextf("invalid table name: %s", table)
1154
        }
1155

1156
        // Use parameterized query with validated table name
1157
        query := fmt.Sprintf("DELETE FROM %s", table)
1158
        if _, err := tx.ExecContext(ctx, query); err != nil {
1159
            return contextutils.WrapErrorf(err, "failed to delete from table %s during reset", table)
1160
        }
1161
        s.logger.Info(ctx, "Cleared table: %s", map[string]interface{}{"table": table})
1162

1163
        // Reset sequence for PostgreSQL
1164
        sequenceQuery := fmt.Sprintf("ALTER SEQUENCE %s_id_seq RESTART WITH 1", table)
1165
        if _, err := tx.ExecContext(ctx, sequenceQuery); err != nil {
1166
            // This might fail if the table doesn't have a sequence, so we log but don't fail
1167
            s.logger.Warn(ctx, "Note: Could not reset sequence for %s (this is normal for some tables)", map[string]interface{}{"table": table})
1168
        }
1169
    }
1170

1171
    err = tx.Commit()
1172
    if err != nil {
1173
        return contextutils.WrapError(err, "failed to commit database reset transaction")
1174
    }
1175

1176
    s.logger.Info(ctx, "Database reset completed successfully")
1177
    return nil
1178
}
1179

1180
// ClearUserData removes all user activity data but keeps the users themselves
1181
1x
func (s *UserService) ClearUserData(ctx context.Context) (err error) {
1182
1x
    ctx, span := observability.TraceUserFunction(ctx, "clear_user_data")
1183
1x
    defer observability.FinishSpan(span, &err)
1184
1x
    var tx *sql.Tx
1185
1x
    tx, err = s.db.Begin()
1186
1x
    if err != nil {
1187
        return contextutils.WrapError(err, "failed to begin transaction for clear user data")
1188
    }
1189
1x
    defer func() {
1190
1x
        if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone {
1191
            s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
1192
        }
1193
    }()
1194

1195
    // Whitelist of valid table names to prevent SQL injection
1196
1x
    validTables := map[string]bool{
1197
1x
        "user_responses":      true,
1198
1x
        "performance_metrics": true,
1199
1x
        "questions":           true,
1200
1x
    }
1201
1x

1202
1x
    // Delete user data but keep users (order matters due to foreign key constraints)
1203
1x
    tables := []string{
1204
1x
        "user_responses",
1205
1x
        "performance_metrics",
1206
1x
        "questions",
1207
1x
    }
1208
1x

1209
1x
    for _, table := range tables {
1210
3x
        // Validate table name against whitelist
1211
3x
        if !validTables[table] {
1212
            return contextutils.ErrorWithContextf("invalid table name: %s", table)
1213
        }
1214

1215
        // Use parameterized query with validated table name
1216
3x
        query := fmt.Sprintf("DELETE FROM %s", table)
1217
3x
        if _, err := tx.ExecContext(ctx, query); err != nil {
1218
            return contextutils.WrapErrorf(err, "failed to delete from table %s during clear user data", table)
1219
        }
1220
3x
        s.logger.Info(ctx, "Cleared table: %s", map[string]interface{}{"table": table})
1221
3x

1222
3x
        // Reset sequence for PostgreSQL
1223
3x
        sequenceQuery := fmt.Sprintf("ALTER SEQUENCE %s_id_seq RESTART WITH 1", table)
1224
3x
        if _, err := tx.ExecContext(ctx, sequenceQuery); err != nil {
1225
            // This might fail if the table doesn't have a sequence, so we log but don't fail
1226
            s.logger.Warn(ctx, "Note: Could not reset sequence for %s (this is normal for some tables)", map[string]interface{}{"table": table})
1227
        }
1228
    }
1229

1230
1x
    err = tx.Commit()
1231
1x
    if err != nil {
1232
        return contextutils.WrapError(err, "failed to commit clear user data transaction")
1233
    }
1234

1235
1x
    s.logger.Info(ctx, "User data cleared successfully (users preserved)")
1236
1x
    return nil
1237
}
1238

1239
// ClearUserDataForUser removes all user activity data for a specific user but keeps the user record
1240
2x
func (s *UserService) ClearUserDataForUser(ctx context.Context, userID int) (err error) {
1241
2x
    ctx, span := observability.TraceUserFunction(ctx, "clear_user_data_for_user", attribute.Int("user.id", userID))
1242
2x
    defer observability.FinishSpan(span, &err)
1243
2x
    var tx *sql.Tx
1244
2x
    tx, err = s.db.Begin()
1245
2x
    if err != nil {
1246
        s.logger.Warn(ctx, "Failed to begin transaction", map[string]interface{}{"error": err.Error()})
1247
        return contextutils.WrapError(err, "failed to begin transaction for clear user data for specific user")
1248
    }
1249
2x
    defer func() {
1250
2x
        if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone {
1251
            s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
1252
        }
1253
    }()
1254

1255
    // Delete user_responses for this user's questions (via user_questions)
1256
2x
    query := `DELETE FROM user_responses WHERE question_id IN (SELECT question_id FROM user_questions WHERE user_id = $1)`
1257
2x
    result, err := tx.ExecContext(ctx, query, userID)
1258
2x
    if err != nil {
1259
        s.logger.Warn(ctx, "Failed to delete user_responses", map[string]interface{}{"error": err.Error()})
1260
        return contextutils.WrapError(err, "failed to delete user responses for specific user")
1261
    }
1262
2x
    rows, _ := result.RowsAffected()
1263
2x
    s.logger.Info(ctx, "Deleted %d user_responses for user %d", map[string]interface{}{"count": rows, "user_id": userID})
1264
2x

1265
2x
    // Delete performance_metrics for this user (performance_metrics has user_id, not question_id)
1266
2x
    query = `DELETE FROM performance_metrics WHERE user_id = $1`
1267
2x
    result, err = tx.ExecContext(ctx, query, userID)
1268
2x
    if err != nil {
1269
        s.logger.Warn(ctx, "Failed to delete performance_metrics", map[string]interface{}{"error": err.Error()})
1270
        return contextutils.WrapError(err, "failed to delete performance metrics for specific user")
1271
    }
1272
2x
    rows, _ = result.RowsAffected()
1273
2x
    s.logger.Info(ctx, "Deleted %d performance_metrics for user %d", map[string]interface{}{"count": rows, "user_id": userID})
1274
2x

1275
2x
    // Delete user_questions for this user
1276
2x
    query = `DELETE FROM user_questions WHERE user_id = $1`
1277
2x
    result, err = tx.ExecContext(ctx, query, userID)
1278
2x
    if err != nil {
1279
        s.logger.Warn(ctx, "Failed to delete user_questions", map[string]interface{}{"error": err.Error()})
1280
        return contextutils.WrapError(err, "failed to delete user questions for specific user")
1281
    }
1282
2x
    rows, _ = result.RowsAffected()
1283
2x
    s.logger.Info(ctx, "Deleted %d user_questions for user %d", map[string]interface{}{"count": rows, "user_id": userID})
1284
2x

1285
2x
    // Optionally, delete orphaned questions (not assigned to any user)
1286
2x
    query = `DELETE FROM questions WHERE id NOT IN (SELECT question_id FROM user_questions)`
1287
2x
    result, err = tx.ExecContext(ctx, query)
1288
2x
    if err != nil {
1289
        s.logger.Warn(ctx, "Failed to delete orphaned questions", map[string]interface{}{"error": err.Error()})
1290
        return contextutils.WrapError(err, "failed to delete orphaned questions")
1291
    }
1292
2x
    rows, _ = result.RowsAffected()
1293
2x
    s.logger.Info(ctx, "Deleted %d orphaned questions", map[string]interface{}{"count": rows})
1294
2x

1295
2x
    if err := tx.Commit(); err != nil {
1296
        s.logger.Warn(ctx, "Failed to commit transaction", map[string]interface{}{"error": err.Error()})
1297
        return contextutils.WrapError(err, "failed to commit clear user data for specific user transaction")
1298
    }
1299
2x
    s.logger.Info(ctx, "User data cleared successfully for user %d (users preserved)", map[string]interface{}{"user_id": userID})
1300
2x
    return nil
1301
}
1302

1303
347x
func (s *UserService) applyDefaultSettings(ctx context.Context, user *models.User) {
1304
347x
    if user == nil || s.cfg == nil {
1305
        return
1306
    }
1307
347x
    _, span := observability.TraceUserFunction(ctx, "apply_default_settings", attribute.Int("user.id", user.ID))
1308
347x
    defer span.End()
1309
347x
    if user.AIProvider.String == "" && len(s.cfg.Providers) > 0 {
1310
301x
        // Use the first available provider as default
1311
301x
        provider := s.cfg.Providers[0]
1312
301x
        user.AIProvider.String = provider.Code
1313
301x
        // Use first model in the list as default
1314
301x
        if len(provider.Models) > 0 {
1315
301x
            user.AIModel.String = provider.Models[0].Code
1316
301x
        }
1317
    }
1318
347x
    if user.CurrentLevel.String == "" {
1319
1x
        // Set default level based on user's preferred language, or use first available language
1320
1x
        language := user.PreferredLanguage.String
1321
1x
        if language == "" {
1322
1x
            languages := s.cfg.GetLanguages()
1323
1x
            if len(languages) > 0 {
1324
1x
                language = languages[0]
1325
1x
            }
1326
        }
1327
1x
        if language != "" {
1328
1x
            levels := s.cfg.GetLevelsForLanguage(language)
1329
1x
            if len(levels) > 0 {
1330
1x
                user.CurrentLevel.String = levels[0]
1331
1x
            }
1332
        }
1333
    }
1334
347x
    if user.PreferredLanguage.String == "" {
1335
1x
        user.PreferredLanguage.String = "english"
1336
1x
    }
1337
}
1338

1339
// GetUserRoles retrieves all roles for a user
1340
365x
func (s *UserService) GetUserRoles(ctx context.Context, userID int) (result0 []models.Role, err error) {
1341
365x
    ctx, span := observability.TraceUserFunction(ctx, "get_user_roles", attribute.Int("user.id", userID))
1342
365x
    defer func() {
1343
365x
        if err != nil {
1344
            span.RecordError(err, trace.WithStackTrace(true))
1345
            span.SetStatus(codes.Error, err.Error())
1346
        }
1347
365x
        span.End()
1348
    }()
1349

1350
365x
    query := `
1351
365x
        SELECT r.id, r.name, r.description, r.created_at, r.updated_at
1352
365x
        FROM roles r
1353
365x
        JOIN user_roles ur ON r.id = ur.role_id
1354
365x
        WHERE ur.user_id = $1
1355
365x
        ORDER BY r.name
1356
365x
    `
1357
365x
    rows, err := s.db.QueryContext(ctx, query, userID)
1358
365x
    if err != nil {
1359
        return nil, contextutils.WrapError(err, "failed to get user roles")
1360
    }
1361
365x
    defer func() {
1362
365x
        if closeErr := rows.Close(); closeErr != nil {
1363
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
1364
        }
1365
    }()
1366

1367
365x
    var roles []models.Role
1368
365x
    for rows.Next() {
1369
88x
        var role models.Role
1370
88x
        err := rows.Scan(&role.ID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt)
1371
88x
        if err != nil {
1372
            return nil, contextutils.WrapError(err, "failed to scan user role")
1373
        }
1374
88x
        roles = append(roles, role)
1375
    }
1376

1377
365x
    if err = rows.Err(); err != nil {
1378
        return nil, contextutils.WrapError(err, "error iterating user roles")
1379
    }
1380

1381
365x
    return roles, nil
1382
}
1383

1384
// AssignRole assigns a role to a user
1385
11x
func (s *UserService) AssignRole(ctx context.Context, userID, roleID int) (err error) {
1386
11x
    ctx, span := observability.TraceUserFunction(ctx, "assign_role", attribute.Int("user.id", userID), attribute.Int("role.id", roleID))
1387
11x
    defer func() {
1388
11x
        if err != nil {
1389
4x
            span.RecordError(err, trace.WithStackTrace(true))
1390
4x
            span.SetStatus(codes.Error, err.Error())
1391
4x
        }
1392
11x
        span.End()
1393
    }()
1394

1395
    // Check if user exists
1396
11x
    user, err := s.GetUserByID(ctx, userID)
1397
11x
    if err != nil {
1398
        return contextutils.WrapError(err, "failed to get user for role assignment")
1399
    }
1400
11x
    if user == nil {
1401
2x
        return contextutils.ErrorWithContextf("user with ID %d not found", userID)
1402
2x
    }
1403

1404
    // Check if role exists
1405
9x
    var roleName string
1406
9x
    err = s.db.QueryRowContext(ctx, "SELECT name FROM roles WHERE id = $1", roleID).Scan(&roleName)
1407
9x
    if err != nil {
1408
2x
        if errors.Is(err, sql.ErrNoRows) {
1409
2x
            return contextutils.ErrorWithContextf("role with ID %d not found", roleID)
1410
2x
        }
1411
        return contextutils.WrapError(err, "failed to check role existence")
1412
    }
1413

1414
    // Assign role (using ON CONFLICT DO NOTHING to handle duplicate assignments gracefully)
1415
7x
    query := `INSERT INTO user_roles (user_id, role_id, created_at) VALUES ($1, $2, $3) ON CONFLICT (user_id, role_id) DO NOTHING`
1416
7x
    _, err = s.db.ExecContext(ctx, query, userID, roleID, time.Now())
1417
7x
    if err != nil {
1418
        return contextutils.WrapError(err, "failed to assign role to user")
1419
    }
1420

1421
7x
    s.logger.Info(ctx, "Role assigned successfully", map[string]interface{}{
1422
7x
        "user_id":   userID,
1423
7x
        "role_id":   roleID,
1424
7x
        "role_name": roleName,
1425
7x
    })
1426
7x

1427
7x
    return nil
1428
}
1429

1430
// AssignRoleByName assigns a role to a user by role name
1431
112x
func (s *UserService) AssignRoleByName(ctx context.Context, userID int, roleName string) (err error) {
1432
112x
    ctx, span := observability.TraceUserFunction(ctx, "assign_role_by_name", attribute.Int("user.id", userID), attribute.String("role.name", roleName))
1433
112x
    defer func() {
1434
112x
        if err != nil {
1435
3x
            span.RecordError(err, trace.WithStackTrace(true))
1436
3x
            span.SetStatus(codes.Error, err.Error())
1437
3x
        }
1438
112x
        span.End()
1439
    }()
1440

1441
    // Check if user exists
1442
112x
    user, err := s.GetUserByID(ctx, userID)
1443
112x
    if err != nil {
1444
        return contextutils.WrapError(err, "failed to get user for role assignment")
1445
    }
1446
112x
    if user == nil {
1447
1x
        return contextutils.ErrorWithContextf("user with ID %d not found", userID)
1448
1x
    }
1449

1450
    // Get role ID by name
1451
111x
    var roleID int
1452
111x
    err = s.db.QueryRowContext(ctx, "SELECT id FROM roles WHERE name = $1", roleName).Scan(&roleID)
1453
111x
    if err != nil {
1454
2x
        if errors.Is(err, sql.ErrNoRows) {
1455
2x
            return contextutils.ErrorWithContextf("role with name '%s' not found", roleName)
1456
2x
        }
1457
        return contextutils.WrapError(err, "failed to get role ID by name")
1458
    }
1459

1460
    // Assign role (using ON CONFLICT DO NOTHING to handle duplicate assignments gracefully)
1461
109x
    query := `INSERT INTO user_roles (user_id, role_id, created_at) VALUES ($1, $2, $3) ON CONFLICT (user_id, role_id) DO NOTHING`
1462
109x
    _, err = s.db.ExecContext(ctx, query, userID, roleID, time.Now())
1463
109x
    if err != nil {
1464
        return contextutils.WrapError(err, "failed to assign role to user")
1465
    }
1466

1467
109x
    s.logger.Info(ctx, "Role assigned successfully", map[string]interface{}{
1468
109x
        "user_id":   userID,
1469
109x
        "role_id":   roleID,
1470
109x
        "role_name": roleName,
1471
109x
    })
1472
109x

1473
109x
    return nil
1474
}
1475

1476
// RemoveRole removes a role from a user
1477
4x
func (s *UserService) RemoveRole(ctx context.Context, userID, roleID int) (err error) {
1478
4x
    ctx, span := observability.TraceUserFunction(ctx, "remove_role", attribute.Int("user.id", userID), attribute.Int("role.id", roleID))
1479
4x
    defer func() {
1480
4x
        if err != nil {
1481
3x
            span.RecordError(err, trace.WithStackTrace(true))
1482
3x
            span.SetStatus(codes.Error, err.Error())
1483
3x
        }
1484
4x
        span.End()
1485
    }()
1486

1487
    // Check if user exists
1488
4x
    user, err := s.GetUserByID(ctx, userID)
1489
4x
    if err != nil {
1490
        return contextutils.WrapError(err, "failed to get user for role removal")
1491
    }
1492
4x
    if user == nil {
1493
1x
        return contextutils.ErrorWithContextf("user with ID %d not found", userID)
1494
1x
    }
1495

1496
    // Check if role exists
1497
3x
    var roleName string
1498
3x
    err = s.db.QueryRowContext(ctx, "SELECT name FROM roles WHERE id = $1", roleID).Scan(&roleName)
1499
3x
    if err != nil {
1500
1x
        if errors.Is(err, sql.ErrNoRows) {
1501
1x
            return contextutils.ErrorWithContextf("role with ID %d not found", roleID)
1502
1x
        }
1503
        return contextutils.WrapError(err, "failed to check role existence")
1504
    }
1505

1506
    // Remove role
1507
2x
    query := `DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2`
1508
2x
    result, err := s.db.ExecContext(ctx, query, userID, roleID)
1509
2x
    if err != nil {
1510
        return contextutils.WrapError(err, "failed to remove role from user")
1511
    }
1512

1513
2x
    rowsAffected, err := result.RowsAffected()
1514
2x
    if err != nil {
1515
        return contextutils.WrapError(err, "failed to get rows affected")
1516
    }
1517

1518
2x
    if rowsAffected == 0 {
1519
1x
        return contextutils.ErrorWithContextf("user %d does not have role %d", userID, roleID)
1520
1x
    }
1521

1522
1x
    s.logger.Info(ctx, "Role removed successfully", map[string]interface{}{
1523
1x
        "user_id":   userID,
1524
1x
        "role_id":   roleID,
1525
1x
        "role_name": roleName,
1526
1x
    })
1527
1x

1528
1x
    return nil
1529
}
1530

1531
// HasRole checks if a user has a specific role by name
1532
19x
func (s *UserService) HasRole(ctx context.Context, userID int, roleName string) (result0 bool, err error) {
1533
19x
    ctx, span := observability.TraceUserFunction(ctx, "has_role", attribute.Int("user.id", userID), attribute.String("role.name", roleName))
1534
19x
    defer func() {
1535
19x
        if err != nil {
1536
            span.RecordError(err, trace.WithStackTrace(true))
1537
            span.SetStatus(codes.Error, err.Error())
1538
        }
1539
19x
        span.End()
1540
    }()
1541

1542
19x
    query := `
1543
19x
        SELECT COUNT(*) > 0
1544
19x
        FROM user_roles ur
1545
19x
        JOIN roles r ON ur.role_id = r.id
1546
19x
        WHERE ur.user_id = $1 AND r.name = $2
1547
19x
    `
1548
19x
    var hasRole bool
1549
19x
    err = s.db.QueryRowContext(ctx, query, userID, roleName).Scan(&hasRole)
1550
19x
    if err != nil {
1551
        return false, contextutils.WrapError(err, "failed to check if user has role")
1552
    }
1553

1554
19x
    return hasRole, nil
1555
}
1556

1557
// IsAdmin checks if a user has admin role
1558
8x
func (s *UserService) IsAdmin(ctx context.Context, userID int) (result0 bool, err error) {
1559
8x
    ctx, span := observability.TraceUserFunction(ctx, "is_admin", attribute.Int("user.id", userID))
1560
8x
    defer observability.FinishSpan(span, &err)
1561
8x

1562
8x
    return s.HasRole(ctx, userID, "admin")
1563
8x
}
1564

1565
// GetAllRoles returns all available roles in the system
1566
func (s *UserService) GetAllRoles(ctx context.Context) (result0 []models.Role, err error) {
1567
    ctx, span := observability.TraceUserFunction(ctx, "get_all_roles")
1568
    defer observability.FinishSpan(span, &err)
1569

1570
    query := `
1571
        SELECT id, name, description, created_at, updated_at
1572
        FROM roles
1573
        ORDER BY name
1574
    `
1575
    rows, err := s.db.QueryContext(ctx, query)
1576
    if err != nil {
1577
        return nil, contextutils.WrapError(err, "failed to get all roles")
1578
    }
1579
    defer func() {
1580
        if closeErr := rows.Close(); closeErr != nil {
1581
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
1582
        }
1583
    }()
1584

1585
    var roles []models.Role
1586
    for rows.Next() {
1587
        var role models.Role
1588
        err := rows.Scan(&role.ID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt)
1589
        if err != nil {
1590
            return nil, contextutils.WrapError(err, "failed to scan role")
1591
        }
1592
        roles = append(roles, role)
1593
    }
1594

1595
    if err = rows.Err(); err != nil {
1596
        return nil, contextutils.WrapError(err, "error iterating roles")
1597
    }
1598

1599
    return roles, nil
1600
}
1601

1602
// GetDB returns the database connection
1603
func (s *UserService) GetDB() *sql.DB {
1604
    return s.db
1605
}
1606

1607
// isDuplicateKeyError checks if the error is a duplicate key constraint violation
1608
func isDuplicateKeyError(err error) bool {
1609
    if err == nil {
1610
        return false
1611
    }
1612

1613
    // Check for PostgreSQL unique constraint violation error code
1614
    if pqErr, ok := err.(*pq.Error); ok {
1615
        // PostgreSQL error code 23505 is for unique constraint violations
1616
        if pqErr.Code == "23505" {
1617
            return true
1618
        }
1619
    }
1620

1621
    return false
1622
}
1623


			
quizapp internal services worker_service.go
83.2%
Statements
89/107
1
package services
2

3
import (
4
    "context"
5
    "math/rand"
6

7
    "go.opentelemetry.io/otel/attribute"
8

9
    "quizapp/internal/config"
10
    "quizapp/internal/observability"
11
)
12

13
// VarietyService handles the selection of variety elements for question generation
14
type VarietyService struct {
15
    cfg    *config.Config
16
    logger *observability.Logger
17
}
18

19
// VarietyElements holds the randomly selected variety elements for a question generation request
20
type VarietyElements struct {
21
    TopicCategory      string
22
    GrammarFocus       string
23
    VocabularyDomain   string
24
    Scenario           string
25
    StyleModifier      string
26
    DifficultyModifier string
27
    TimeContext        string
28
}
29

30
// NewVarietyServiceWithLogger creates a new VarietyService with logger
31
72x
func NewVarietyServiceWithLogger(cfg *config.Config, logger *observability.Logger) *VarietyService {
32
72x
    return &VarietyService{
33
72x
        cfg:    cfg,
34
72x
        logger: logger,
35
72x
    }
36
72x
}
37

38
// SelectVarietyElements randomly selects variety elements for question generation
39
// If highPriorityTopics or userWeakAreas are provided, bias topic selection toward those topics first, then gapAnalysis.
40
1277x
func (vs *VarietyService) SelectVarietyElements(ctx context.Context, level string, highPriorityTopics, userWeakAreas []string, gapAnalysis map[string]int) *VarietyElements {
41
1277x
    _, span := observability.TraceVarietyFunction(ctx, "select_variety_elements",
42
1277x
        attribute.String("variety.level", level),
43
1277x
        attribute.Int("variety.high_priority_topics_count", len(highPriorityTopics)),
44
1277x
        attribute.Int("variety.user_weak_areas_count", len(userWeakAreas)),
45
1277x
        attribute.Int("variety.gap_analysis_count", len(gapAnalysis)),
46
1277x
    )
47
1277x
    defer span.End()
48
1277x

49
1277x
    // Get variety configuration from config
50
1277x
    if vs.cfg.Variety != nil {
51
1276x
        variety := vs.cfg.Variety
52
1276x
        elements := &VarietyElements{}
53
1276x

54
1276x
        // Helper function to get weighted selection from gap analysis
55
1276x
        getWeightedSelection := func(gapType string, availableOptions []string) string {
56
3387x
            if len(gapAnalysis) == 0 || len(availableOptions) == 0 {
57
609x
                return ""
58
609x
            }
59

60
2778x
            var weightedOptions []string
61
2778x
            for _, option := range availableOptions {
62
9039x
                gapKey := gapType + "_" + option
63
9039x
                if count, ok := gapAnalysis[gapKey]; ok && count > 0 {
64
1473x
                    // Intensify weighting by squaring the severity to reduce randomness sensitivity
65
1473x
                    weight := count * count
66
1473x
                    for range weight {
67
8375x
                        weightedOptions = append(weightedOptions, option)
68
8375x
                    }
69
                }
70
            }
71

72
2778x
            if len(weightedOptions) > 0 {
73
1030x
                return weightedOptions[rand.Intn(len(weightedOptions))]
74
1030x
            }
75
1748x
            return ""
76
        }
77

78
        // Define all possible variety elements with their selection functions
79
1276x
        type varietySelector struct {
80
1276x
            name     string
81
1276x
            selector func() string
82
1276x
        }
83
1276x

84
1276x
        var selectors []varietySelector
85
1276x

86
1276x
        // Topic category selector (biased by userWeakAreas, highPriorityTopics, then gapAnalysis if provided)
87
1276x
        if len(variety.TopicCategories) > 0 {
88
1276x
            selectors = append(selectors, varietySelector{
89
1276x
                name: "topic_category",
90
1276x
                selector: func() string {
91
864x
                    // 1. UserWeakAreas
92
864x
                    if len(userWeakAreas) > 0 {
93
                        var matching []string
94
                        for _, topic := range variety.TopicCategories {
95
                            for _, weak := range userWeakAreas {
96
                                if topic == weak {
97
                                    matching = append(matching, topic)
98
                                }
99
                            }
100
                        }
101
                        if len(matching) > 0 {
102
                            elements.TopicCategory = matching[rand.Intn(len(matching))]
103
                            return elements.TopicCategory
104
                        }
105
                    }
106
                    // 2. HighPriorityTopics
107
864x
                    if len(highPriorityTopics) > 0 {
108
                        var matching []string
109
                        for _, topic := range variety.TopicCategories {
110
                            for _, high := range highPriorityTopics {
111
                                if topic == high {
112
                                    matching = append(matching, topic)
113
                                }
114
                            }
115
                        }
116
                        if len(matching) > 0 {
117
                            elements.TopicCategory = matching[rand.Intn(len(matching))]
118
                            return elements.TopicCategory
119
                        }
120
                    }
121
                    // 3. GapAnalysis for topics
122
864x
                    if selected := getWeightedSelection("topic_category", variety.TopicCategories); selected != "" {
123
381x
                        elements.TopicCategory = selected
124
381x
                        return elements.TopicCategory
125
381x
                    }
126
                    // Fallback to random
127
483x
                    elements.TopicCategory = variety.TopicCategories[rand.Intn(len(variety.TopicCategories))]
128
483x
                    return elements.TopicCategory
129
                },
130
            })
131
        }
132

133
        // Grammar focus selector (now with gap analysis support)
134
1276x
        if grammarByLevel, exists := variety.GrammarFocusByLevel[level]; exists && len(grammarByLevel) > 0 {
135
1275x
            selectors = append(selectors, varietySelector{
136
1275x
                name: "grammar_focus",
137
1275x
                selector: func() string {
138
825x
                    // Check for grammar gaps first
139
825x
                    if selected := getWeightedSelection("grammar_focus", grammarByLevel); selected != "" {
140
204x
                        elements.GrammarFocus = selected
141
204x
                        return elements.GrammarFocus
142
204x
                    }
143
                    // Fallback to random
144
621x
                    elements.GrammarFocus = grammarByLevel[rand.Intn(len(grammarByLevel))]
145
621x
                    return elements.GrammarFocus
146
                },
147
            })
148
1x
        } else if len(variety.GrammarFocus) > 0 {
149
1x
            selectors = append(selectors, varietySelector{
150
1x
                name: "grammar_focus",
151
1x
                selector: func() string {
152
1x
                    // Check for grammar gaps first
153
1x
                    if selected := getWeightedSelection("grammar_focus", variety.GrammarFocus); selected != "" {
154
                        elements.GrammarFocus = selected
155
                        return elements.GrammarFocus
156
                    }
157
                    // Fallback to random
158
1x
                    elements.GrammarFocus = variety.GrammarFocus[rand.Intn(len(variety.GrammarFocus))]
159
1x
                    return elements.GrammarFocus
160
                },
161
            })
162
        }
163

164
        // Vocabulary domain selector (now with gap analysis support)
165
1276x
        if len(variety.VocabularyDomains) > 0 {
166
1273x
            selectors = append(selectors, varietySelector{
167
1273x
                name: "vocabulary_domain",
168
1273x
                selector: func() string {
169
851x
                    // Check for vocabulary gaps first
170
851x
                    if selected := getWeightedSelection("vocabulary_domain", variety.VocabularyDomains); selected != "" {
171
197x
                        elements.VocabularyDomain = selected
172
197x
                        return elements.VocabularyDomain
173
197x
                    }
174
                    // Fallback to random
175
654x
                    elements.VocabularyDomain = variety.VocabularyDomains[rand.Intn(len(variety.VocabularyDomains))]
176
654x
                    return elements.VocabularyDomain
177
                },
178
            })
179
        }
180

181
        // Scenario selector (now with gap analysis support)
182
1276x
        if len(variety.Scenarios) > 0 {
183
1273x
            selectors = append(selectors, varietySelector{
184
1273x
                name: "scenario",
185
1273x
                selector: func() string {
186
846x
                    // Check for scenario gaps first
187
846x
                    if selected := getWeightedSelection("scenario", variety.Scenarios); selected != "" {
188
248x
                        elements.Scenario = selected
189
248x
                        return elements.Scenario
190
248x
                    }
191
                    // Fallback to random
192
598x
                    elements.Scenario = variety.Scenarios[rand.Intn(len(variety.Scenarios))]
193
598x
                    return elements.Scenario
194
                },
195
            })
196
        }
197

198
        // Style modifier selector
199
1276x
        if len(variety.StyleModifiers) > 0 {
200
1273x
            selectors = append(selectors, varietySelector{
201
1273x
                name: "style_modifier",
202
1273x
                selector: func() string {
203
823x
                    elements.StyleModifier = variety.StyleModifiers[rand.Intn(len(variety.StyleModifiers))]
204
823x
                    return elements.StyleModifier
205
823x
                },
206
            })
207
        }
208

209
        // Difficulty modifier selector
210
1276x
        if len(variety.DifficultyModifiers) > 0 {
211
1273x
            selectors = append(selectors, varietySelector{
212
1273x
                name: "difficulty_modifier",
213
1273x
                selector: func() string {
214
849x
                    elements.DifficultyModifier = variety.DifficultyModifiers[rand.Intn(len(variety.DifficultyModifiers))]
215
849x
                    return elements.DifficultyModifier
216
849x
                },
217
            })
218
        }
219

220
        // Time context selector
221
1276x
        if len(variety.TimeContexts) > 0 {
222
1273x
            selectors = append(selectors, varietySelector{
223
1273x
                name: "time_context",
224
1273x
                selector: func() string {
225
858x
                    elements.TimeContext = variety.TimeContexts[rand.Intn(len(variety.TimeContexts))]
226
858x
                    return elements.TimeContext
227
858x
                },
228
            })
229
        }
230

231
        // Randomly select 2-3 variety elements (instead of all 7)
232
1276x
        numToSelect := 2
233
1276x
        if len(selectors) > 2 {
234
1273x
            // 70% chance of 2 elements, 30% chance of 3 elements
235
1273x
            if rand.Float64() < 0.3 {
236
813x
                numToSelect = 3
237
813x
            }
238
        }
239

240
        // Shuffle and select the first numToSelect elements
241
1276x
        rand.Shuffle(len(selectors), func(i, j int) {
242
7641x
            selectors[i], selectors[j] = selectors[j], selectors[i]
243
7641x
        })
244

245
        // Apply the selected variety elements
246
1276x
        for i := 0; i < numToSelect && i < len(selectors); i++ {
247
5917x
            selected := selectors[i].selector()
248
5917x
            span.SetAttributes(attribute.String("variety."+selectors[i].name, selected))
249
5917x
        }
250

251
1276x
        span.SetAttributes(
252
1276x
            attribute.String("variety.topic_category", elements.TopicCategory),
253
1276x
            attribute.String("variety.grammar_focus", elements.GrammarFocus),
254
1276x
            attribute.String("variety.vocabulary_domain", elements.VocabularyDomain),
255
1276x
            attribute.String("variety.scenario", elements.Scenario),
256
1276x
            attribute.String("variety.style_modifier", elements.StyleModifier),
257
1276x
            attribute.String("variety.difficulty_modifier", elements.DifficultyModifier),
258
1276x
            attribute.String("variety.time_context", elements.TimeContext),
259
1276x
            attribute.Int("variety.elements_selected", numToSelect),
260
1276x
        )
261
1276x

262
1276x
        span.SetAttributes(attribute.String("variety.result", "success"))
263
1276x
        return elements
264
    }
265

266
1x
    span.SetAttributes(attribute.String("variety.result", "no_config"))
267
1x
    return &VarietyElements{} // Return empty if no variety config
268
}
269

270
// SelectMultipleVarietyElements selects multiple sets of variety elements for batch generation
271
1x
func (vs *VarietyService) SelectMultipleVarietyElements(ctx context.Context, level string, count int) []*VarietyElements {
272
1x
    ctx, span := observability.TraceVarietyFunction(ctx, "select_multiple_variety_elements",
273
1x
        attribute.String("variety.level", level),
274
1x
        attribute.Int("variety.count", count),
275
1x
    )
276
1x
    defer span.End()
277
1x

278
1x
    elements := make([]*VarietyElements, count)
279
1x
    for i := 0; i < count; i++ {
280
3x
        elements[i] = vs.SelectVarietyElements(ctx, level, nil, nil, nil)
281
3x
    }
282

283
1x
    span.SetAttributes(attribute.String("variety.result", "success"), attribute.Int("variety.elements_count", len(elements)))
284
1x
    return elements
285
}
286


			
quizapp internal services worker_service.go
43.7%
Statements
93/213
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "encoding/json"
7
    "errors"
8
    "fmt"
9
    "math/rand"
10
    "time"
11

12
    "quizapp/internal/models"
13
    "quizapp/internal/observability"
14
    contextutils "quizapp/internal/utils"
15

16
    "go.opentelemetry.io/otel"
17
    "go.opentelemetry.io/otel/attribute"
18
    "go.opentelemetry.io/otel/codes"
19
    "go.opentelemetry.io/otel/trace"
20
)
21

22
// WordOfTheDayServiceInterface defines the interface for word of the day operations
23
type WordOfTheDayServiceInterface interface {
24
    GetWordOfTheDay(ctx context.Context, userID int, date time.Time) (*models.WordOfTheDayDisplay, error)
25
    SelectWordOfTheDay(ctx context.Context, userID int, date time.Time) (*models.WordOfTheDayDisplay, error)
26
    GetWordHistory(ctx context.Context, userID int, startDate, endDate time.Time) ([]*models.WordOfTheDayDisplay, error)
27
}
28

29
// WordOfTheDayService implements word of the day operations
30
type WordOfTheDayService struct {
31
    db     *sql.DB
32
    logger *observability.Logger
33
}
34

35
// ErrNoSuitableWord indicates there was no suitable word available for the user/date.
36
var ErrNoSuitableWord = errors.New("no suitable word found")
37

38
// NewWordOfTheDayService creates a new WordOfTheDayService instance
39
1x
func NewWordOfTheDayService(db *sql.DB, logger *observability.Logger) *WordOfTheDayService {
40
1x
    return &WordOfTheDayService{
41
1x
        db:     db,
42
1x
        logger: logger,
43
1x
    }
44
1x
}
45

46
// GetWordOfTheDay retrieves the word of the day for a user and date
47
// If not exists, it will generate one by calling SelectWordOfTheDay
48
2x
func (s *WordOfTheDayService) GetWordOfTheDay(ctx context.Context, userID int, date time.Time) (*models.WordOfTheDayDisplay, error) {
49
2x
    ctx, span := otel.Tracer("word-of-the-day-service").Start(ctx, "GetWordOfTheDay",
50
2x
        trace.WithAttributes(
51
2x
            attribute.Int("user.id", userID),
52
2x
            attribute.String("date", date.Format("2006-01-02")),
53
2x
        ),
54
2x
    )
55
2x
    defer observability.FinishSpan(span, nil)
56
2x

57
2x
    // Normalize date to just the date part (no time)
58
2x
    date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC)
59
2x

60
2x
    // Try to get existing word of the day
61
2x
    // Attach username to span (best-effort)
62
2x
    if u, _ := s.getUserByID(ctx, userID); u != nil {
63
2x
        span.SetAttributes(attribute.String("user.username", u.Username))
64
2x
    }
65
2x
    word, err := s.getWordOfTheDayFromDB(ctx, userID, date)
66
2x
    if err != nil && err != sql.ErrNoRows {
67
        span.RecordError(err, trace.WithStackTrace(true))
68
        span.SetStatus(codes.Error, err.Error())
69
        return nil, contextutils.WrapError(err, "failed to get word of the day from database")
70
    }
71

72
    // If exists, return it
73
2x
    if word != nil {
74
1x
        span.SetAttributes(
75
1x
            attribute.String("source_type", string(word.SourceType)),
76
1x
            attribute.Int("source_id", word.SourceID),
77
1x
        )
78
1x
        return s.convertToDisplay(ctx, word)
79
1x
    }
80

81
    // If not exists, generate one
82
1x
    s.logger.Info(ctx, "Word of the day not found, generating new one", map[string]interface{}{
83
1x
        "user_id": userID,
84
1x
        "date":    date.Format("2006-01-02"),
85
1x
    })
86
1x

87
1x
    return s.SelectWordOfTheDay(ctx, userID, date)
88
}
89

90
// SelectWordOfTheDay selects and assigns a word of the day for a user and date
91
1x
func (s *WordOfTheDayService) SelectWordOfTheDay(ctx context.Context, userID int, date time.Time) (*models.WordOfTheDayDisplay, error) {
92
1x
    ctx, span := otel.Tracer("word-of-the-day-service").Start(ctx, "SelectWordOfTheDay",
93
1x
        trace.WithAttributes(
94
1x
            attribute.Int("user.id", userID),
95
1x
            attribute.String("date", date.Format("2006-01-02")),
96
1x
        ),
97
1x
    )
98
1x
    defer observability.FinishSpan(span, nil)
99
1x

100
1x
    // Normalize date to just the date part (no time)
101
1x
    date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC)
102
1x

103
1x
    // Get user preferences
104
1x
    user, err := s.getUserByID(ctx, userID)
105
1x
    if err != nil {
106
        span.RecordError(err, trace.WithStackTrace(true))
107
        span.SetStatus(codes.Error, err.Error())
108
        return nil, contextutils.WrapError(err, "failed to get user")
109
    }
110

111
1x
    if user == nil {
112
        err := contextutils.ErrorWithContextf("user not found: %d", userID)
113
        span.RecordError(err, trace.WithStackTrace(true))
114
        span.SetStatus(codes.Error, err.Error())
115
        return nil, err
116
    }
117

118
1x
    language := user.PreferredLanguage.String
119
1x
    level := user.CurrentLevel.String
120
1x

121
1x
    if language == "" {
122
        err := contextutils.ErrorWithContextf("user missing language preference")
123
        span.RecordError(err, trace.WithStackTrace(true))
124
        span.SetStatus(codes.Error, err.Error())
125
        return nil, err
126
    }
127

128
1x
    span.SetAttributes(
129
1x
        attribute.String("language", language),
130
1x
        attribute.String("level", level),
131
1x
        attribute.String("user.username", user.Username),
132
1x
    )
133
1x

134
1x
    // Randomly decide between vocabulary question (70%) or snippet (30%)
135
1x
    useVocabulary := rand.Float32() < 0.7
136
1x

137
1x
    var word *models.WordOfTheDay
138
1x
    if useVocabulary {
139
1x
        word, err = s.selectVocabularyQuestion(ctx, userID, language, level, date)
140
1x
        if err != nil || word == nil {
141
            s.logger.Warn(ctx, "Failed to select vocabulary question, trying snippet instead", map[string]interface{}{
142
                "error": err,
143
            })
144
            // Fallback to snippet
145
            word, err = s.selectSnippet(ctx, userID, language, date)
146
        }
147
    } else {
148
        word, err = s.selectSnippet(ctx, userID, language, date)
149
        if err != nil || word == nil {
150
            s.logger.Warn(ctx, "Failed to select snippet, trying vocabulary question instead", map[string]interface{}{
151
                "error": err,
152
            })
153
            // Fallback to vocabulary question
154
            word, err = s.selectVocabularyQuestion(ctx, userID, language, level, date)
155
        }
156
    }
157

158
1x
    if err != nil {
159
        span.RecordError(err, trace.WithStackTrace(true))
160
        span.SetStatus(codes.Error, err.Error())
161
        return nil, contextutils.WrapError(err, "failed to select word of the day")
162
    }
163

164
1x
    if word == nil {
165
        // No available word is a normal condition: surface as a typed sentinel without error status
166
        span.SetAttributes(attribute.Bool("no_word_available", true))
167
        return nil, ErrNoSuitableWord
168
    }
169

170
    // Save to database
171
1x
    err = s.saveWordOfTheDay(ctx, word)
172
1x
    if err != nil {
173
        span.RecordError(err, trace.WithStackTrace(true))
174
        span.SetStatus(codes.Error, err.Error())
175
        return nil, contextutils.WrapError(err, "failed to save word of the day")
176
    }
177

178
1x
    span.SetAttributes(
179
1x
        attribute.String("source_type", string(word.SourceType)),
180
1x
        attribute.Int("source_id", word.SourceID),
181
1x
    )
182
1x

183
1x
    s.logger.Info(ctx, "Word of the day selected", map[string]interface{}{
184
1x
        "user_id":     userID,
185
1x
        "date":        date.Format("2006-01-02"),
186
1x
        "source_type": word.SourceType,
187
1x
        "source_id":   word.SourceID,
188
1x
    })
189
1x

190
1x
    return s.convertToDisplay(ctx, word)
191
}
192

193
// GetWordHistory retrieves word of the day history for a date range
194
func (s *WordOfTheDayService) GetWordHistory(ctx context.Context, userID int, startDate, endDate time.Time) ([]*models.WordOfTheDayDisplay, error) {
195
    ctx, span := otel.Tracer("word-of-the-day-service").Start(ctx, "GetWordHistory",
196
        trace.WithAttributes(
197
            attribute.Int("user.id", userID),
198
            attribute.String("start_date", startDate.Format("2006-01-02")),
199
            attribute.String("end_date", endDate.Format("2006-01-02")),
200
        ),
201
    )
202
    defer observability.FinishSpan(span, nil)
203

204
    if u, _ := s.getUserByID(ctx, userID); u != nil {
205
        span.SetAttributes(attribute.String("user.username", u.Username))
206
    }
207

208
    // Normalize dates
209
    startDate = time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, time.UTC)
210
    endDate = time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, time.UTC)
211

212
    query := `
213
        SELECT id, user_id, assignment_date, source_type, source_id, created_at
214
        FROM word_of_the_day
215
        WHERE user_id = $1 AND assignment_date >= $2 AND assignment_date <= $3
216
        ORDER BY assignment_date DESC
217
    `
218

219
    rows, err := s.db.QueryContext(ctx, query, userID, startDate, endDate)
220
    if err != nil {
221
        span.RecordError(err, trace.WithStackTrace(true))
222
        span.SetStatus(codes.Error, err.Error())
223
        return nil, contextutils.WrapError(err, "failed to query word history")
224
    }
225
    defer func() {
226
        if closeErr := rows.Close(); closeErr != nil {
227
            span.RecordError(closeErr, trace.WithStackTrace(true))
228
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": closeErr.Error()})
229
        }
230
    }()
231

232
    var words []*models.WordOfTheDay
233
    for rows.Next() {
234
        var w models.WordOfTheDay
235
        err := rows.Scan(&w.ID, &w.UserID, &w.AssignmentDate, &w.SourceType, &w.SourceID, &w.CreatedAt)
236
        if err != nil {
237
            span.RecordError(err, trace.WithStackTrace(true))
238
            span.SetStatus(codes.Error, err.Error())
239
            return nil, contextutils.WrapError(err, "failed to scan word row")
240
        }
241
        words = append(words, &w)
242
    }
243

244
    if err = rows.Err(); err != nil {
245
        span.RecordError(err, trace.WithStackTrace(true))
246
        span.SetStatus(codes.Error, err.Error())
247
        return nil, contextutils.WrapError(err, "error iterating word rows")
248
    }
249

250
    // Convert to display format
251
    var displays []*models.WordOfTheDayDisplay
252
    for _, w := range words {
253
        display, err := s.convertToDisplay(ctx, w)
254
        if err != nil {
255
            s.logger.Error(ctx, "Failed to convert word to display", err, map[string]interface{}{
256
                "word_id":     w.ID,
257
                "source_type": w.SourceType,
258
                "source_id":   w.SourceID,
259
            })
260
            continue
261
        }
262
        displays = append(displays, display)
263
    }
264

265
    span.SetAttributes(attribute.Int("count", len(displays)))
266

267
    return displays, nil
268
}
269

270
// selectVocabularyQuestion selects a vocabulary question for word of the day
271
1x
func (s *WordOfTheDayService) selectVocabularyQuestion(ctx context.Context, userID int, language, level string, date time.Time) (*models.WordOfTheDay, error) {
272
1x
    ctx, span := otel.Tracer("word-of-the-day-service").Start(ctx, "selectVocabularyQuestion",
273
1x
        trace.WithAttributes(
274
1x
            attribute.Int("user.id", userID),
275
1x
            attribute.String("language", language),
276
1x
            attribute.String("level", level),
277
1x
        ),
278
1x
    )
279
1x
    defer observability.FinishSpan(span, nil)
280
1x

281
1x
    if u, _ := s.getUserByID(ctx, userID); u != nil {
282
1x
        span.SetAttributes(attribute.String("user.username", u.Username))
283
1x
    }
284

285
    // Query for vocabulary questions that haven't been used as word of the day recently
286
1x
    query := `
287
1x
        SELECT q.id
288
1x
        FROM questions q
289
1x
        WHERE q.type = 'vocabulary'
290
1x
          AND q.language = $1
291
1x
          AND q.status = 'active'
292
1x
          AND ($2 = '' OR q.level = $2)
293
1x
          AND NOT EXISTS (
294
1x
            SELECT 1 FROM word_of_the_day wotd
295
1x
            WHERE wotd.user_id = $3
296
1x
              AND wotd.source_type = 'vocabulary_question'
297
1x
              AND wotd.source_id = q.id
298
1x
              AND wotd.assignment_date > $4
299
1x
          )
300
1x
        ORDER BY RANDOM()
301
1x
        LIMIT 1
302
1x
    `
303
1x

304
1x
    // Don't reuse words from the last 60 days
305
1x
    cutoffDate := date.AddDate(0, 0, -60)
306
1x

307
1x
    var questionID int
308
1x
    err := s.db.QueryRowContext(ctx, query, language, level, userID, cutoffDate).Scan(&questionID)
309
1x
    if err == sql.ErrNoRows {
310
        // Try without the recency check
311
        queryNoRecency := `
312
            SELECT q.id
313
            FROM questions q
314
            WHERE q.type = 'vocabulary'
315
              AND q.language = $1
316
              AND q.status = 'active'
317
              AND ($2 = '' OR q.level = $2)
318
            ORDER BY RANDOM()
319
            LIMIT 1
320
        `
321
        err = s.db.QueryRowContext(ctx, queryNoRecency, language, level).Scan(&questionID)
322
    }
323

324
1x
    if err != nil {
325
        if err == sql.ErrNoRows {
326
            return nil, nil // No vocabulary questions available
327
        }
328
        span.RecordError(err, trace.WithStackTrace(true))
329
        span.SetStatus(codes.Error, err.Error())
330
        return nil, contextutils.WrapError(err, "failed to query vocabulary question")
331
    }
332

333
1x
    return &models.WordOfTheDay{
334
1x
        UserID:         userID,
335
1x
        AssignmentDate: date,
336
1x
        SourceType:     models.WordSourceVocabularyQuestion,
337
1x
        SourceID:       questionID,
338
1x
    }, nil
339
}
340

341
// selectSnippet selects a user snippet for word of the day
342
func (s *WordOfTheDayService) selectSnippet(ctx context.Context, userID int, language string, date time.Time) (*models.WordOfTheDay, error) {
343
    ctx, span := otel.Tracer("word-of-the-day-service").Start(ctx, "selectSnippet",
344
        trace.WithAttributes(
345
            attribute.Int("user.id", userID),
346
            attribute.String("language", language),
347
        ),
348
    )
349
    defer observability.FinishSpan(span, nil)
350

351
    if u, _ := s.getUserByID(ctx, userID); u != nil {
352
        span.SetAttributes(attribute.String("user.username", u.Username))
353
    }
354

355
    // Query for user's snippets that haven't been used as word of the day recently
356
    // Prefer more recent snippets (created in last 30 days)
357
    query := `
358
        SELECT s.id
359
        FROM snippets s
360
        WHERE s.user_id = $1
361
          AND s.source_language = $2
362
          AND NOT EXISTS (
363
            SELECT 1 FROM word_of_the_day wotd
364
            WHERE wotd.user_id = $1
365
              AND wotd.source_type = 'snippet'
366
              AND wotd.source_id = s.id
367
              AND wotd.assignment_date > $3
368
          )
369
        ORDER BY
370
          CASE WHEN s.created_at > $4 THEN 0 ELSE 1 END,
371
          RANDOM()
372
        LIMIT 1
373
    `
374

375
    // Don't reuse snippets from the last 60 days
376
    cutoffDate := date.AddDate(0, 0, -60)
377
    // Prefer snippets from the last 30 days
378
    recentCutoff := date.AddDate(0, 0, -30)
379

380
    var snippetID int
381
    err := s.db.QueryRowContext(ctx, query, userID, language, cutoffDate, recentCutoff).Scan(&snippetID)
382
    if err == sql.ErrNoRows {
383
        // Try without the recency check
384
        queryNoRecency := `
385
            SELECT s.id
386
            FROM snippets s
387
            WHERE s.user_id = $1
388
              AND s.source_language = $2
389
            ORDER BY RANDOM()
390
            LIMIT 1
391
        `
392
        err = s.db.QueryRowContext(ctx, queryNoRecency, userID, language).Scan(&snippetID)
393
    }
394

395
    if err != nil {
396
        if err == sql.ErrNoRows {
397
            return nil, nil // No snippets available
398
        }
399
        span.RecordError(err, trace.WithStackTrace(true))
400
        span.SetStatus(codes.Error, err.Error())
401
        return nil, contextutils.WrapError(err, "failed to query snippet")
402
    }
403

404
    return &models.WordOfTheDay{
405
        UserID:         userID,
406
        AssignmentDate: date,
407
        SourceType:     models.WordSourceSnippet,
408
        SourceID:       snippetID,
409
    }, nil
410
}
411

412
// getWordOfTheDayFromDB retrieves a word of the day from the database
413
2x
func (s *WordOfTheDayService) getWordOfTheDayFromDB(ctx context.Context, userID int, date time.Time) (*models.WordOfTheDay, error) {
414
2x
    query := `
415
2x
        SELECT id, user_id, assignment_date, source_type, source_id, created_at
416
2x
        FROM word_of_the_day
417
2x
        WHERE user_id = $1 AND assignment_date = $2
418
2x
    `
419
2x

420
2x
    var w models.WordOfTheDay
421
2x
    err := s.db.QueryRowContext(ctx, query, userID, date).Scan(
422
2x
        &w.ID, &w.UserID, &w.AssignmentDate, &w.SourceType, &w.SourceID, &w.CreatedAt,
423
2x
    )
424
2x

425
2x
    if err == sql.ErrNoRows {
426
1x
        return nil, sql.ErrNoRows
427
1x
    }
428

429
1x
    if err != nil {
430
        return nil, contextutils.WrapError(err, "failed to query word of the day")
431
    }
432

433
1x
    return &w, nil
434
}
435

436
// saveWordOfTheDay saves a word of the day to the database
437
1x
func (s *WordOfTheDayService) saveWordOfTheDay(ctx context.Context, word *models.WordOfTheDay) error {
438
1x
    query := `
439
1x
        INSERT INTO word_of_the_day (user_id, assignment_date, source_type, source_id, created_at)
440
1x
        VALUES ($1, $2, $3, $4, $5)
441
1x
        ON CONFLICT (user_id, assignment_date) DO NOTHING
442
1x
        RETURNING id
443
1x
    `
444
1x

445
1x
    err := s.db.QueryRowContext(ctx, query,
446
1x
        word.UserID,
447
1x
        word.AssignmentDate,
448
1x
        word.SourceType,
449
1x
        word.SourceID,
450
1x
        time.Now(),
451
1x
    ).Scan(&word.ID)
452
1x
    if err != nil {
453
        return contextutils.WrapError(err, "failed to insert word of the day")
454
    }
455

456
1x
    return nil
457
}
458

459
// convertToDisplay converts a WordOfTheDay to WordOfTheDayDisplay format
460
2x
func (s *WordOfTheDayService) convertToDisplay(ctx context.Context, word *models.WordOfTheDay) (*models.WordOfTheDayDisplay, error) {
461
2x
    ctx, span := otel.Tracer("word-of-the-day-service").Start(ctx, "convertToDisplay")
462
2x
    defer observability.FinishSpan(span, nil)
463
2x

464
2x
    if u, _ := s.getUserByID(ctx, word.UserID); u != nil {
465
2x
        span.SetAttributes(
466
2x
            attribute.Int("user.id", u.ID),
467
2x
            attribute.String("user.username", u.Username),
468
2x
        )
469
2x
    }
470

471
2x
    display := &models.WordOfTheDayDisplay{
472
2x
        Date:       word.AssignmentDate,
473
2x
        SourceType: word.SourceType,
474
2x
        SourceID:   word.SourceID,
475
2x
    }
476
2x

477
2x
    switch word.SourceType {
478
2x
    case models.WordSourceVocabularyQuestion:
479
2x
        question, err := s.getQuestionByID(ctx, word.SourceID)
480
2x
        if err != nil {
481
            span.RecordError(err, trace.WithStackTrace(true))
482
            span.SetStatus(codes.Error, err.Error())
483
            return nil, contextutils.WrapError(err, "failed to get question")
484
        }
485

486
        // Extract word, translation, and sentence from question content
487
2x
        content := question.Content
488
2x
        if sentenceRaw, ok := content["sentence"]; ok {
489
2x
            display.Sentence = fmt.Sprintf("%v", sentenceRaw)
490
2x
        }
491
2x
        if questionRaw, ok := content["question"]; ok {
492
2x
            display.Word = fmt.Sprintf("%v", questionRaw)
493
2x
        }
494
2x
        if optionsRaw, ok := content["options"]; ok {
495
2x
            if options, ok := optionsRaw.([]interface{}); ok && len(options) > question.CorrectAnswer {
496
2x
                display.Translation = fmt.Sprintf("%v", options[question.CorrectAnswer])
497
2x
            }
498
        }
499

500
2x
        display.Language = question.Language
501
2x
        display.Level = question.Level
502
2x
        display.Explanation = question.Explanation
503
2x
        display.TopicCategory = question.TopicCategory
504

505
    case models.WordSourceSnippet:
506
        snippet, err := s.getSnippetByID(ctx, word.SourceID)
507
        if err != nil {
508
            span.RecordError(err, trace.WithStackTrace(true))
509
            span.SetStatus(codes.Error, err.Error())
510
            return nil, contextutils.WrapError(err, "failed to get snippet")
511
        }
512

513
        display.Word = snippet.OriginalText
514
        display.Translation = snippet.TranslatedText
515
        display.Language = snippet.SourceLanguage
516
        if snippet.Context != nil {
517
            display.Context = *snippet.Context
518
            display.Sentence = *snippet.Context
519
        }
520
        if snippet.DifficultyLevel != nil {
521
            display.Level = *snippet.DifficultyLevel
522
        }
523
    }
524

525
2x
    return display, nil
526
}
527

528
// getUserByID retrieves a user by ID
529
6x
func (s *WordOfTheDayService) getUserByID(ctx context.Context, userID int) (*models.User, error) {
530
6x
    query := `
531
6x
        SELECT id, username, email, preferred_language, current_level, timezone
532
6x
        FROM users
533
6x
        WHERE id = $1
534
6x
    `
535
6x

536
6x
    var user models.User
537
6x
    err := s.db.QueryRowContext(ctx, query, userID).Scan(
538
6x
        &user.ID,
539
6x
        &user.Username,
540
6x
        &user.Email,
541
6x
        &user.PreferredLanguage,
542
6x
        &user.CurrentLevel,
543
6x
        &user.Timezone,
544
6x
    )
545
6x

546
6x
    if err == sql.ErrNoRows {
547
        return nil, nil
548
    }
549

550
6x
    if err != nil {
551
        return nil, contextutils.WrapError(err, "failed to query user")
552
    }
553

554
6x
    return &user, nil
555
}
556

557
// getQuestionByID retrieves a question by ID
558
2x
func (s *WordOfTheDayService) getQuestionByID(ctx context.Context, questionID int) (*models.Question, error) {
559
2x
    query := `
560
2x
        SELECT id, type, language, level, difficulty_score, content, correct_answer,
561
2x
               explanation, created_at, status, topic_category, grammar_focus,
562
2x
               vocabulary_domain, scenario, style_modifier, difficulty_modifier, time_context
563
2x
        FROM questions
564
2x
        WHERE id = $1
565
2x
    `
566
2x

567
2x
    var question models.Question
568
2x
    var contentJSON []byte
569
2x

570
2x
    err := s.db.QueryRowContext(ctx, query, questionID).Scan(
571
2x
        &question.ID,
572
2x
        &question.Type,
573
2x
        &question.Language,
574
2x
        &question.Level,
575
2x
        &question.DifficultyScore,
576
2x
        &contentJSON,
577
2x
        &question.CorrectAnswer,
578
2x
        &question.Explanation,
579
2x
        &question.CreatedAt,
580
2x
        &question.Status,
581
2x
        &question.TopicCategory,
582
2x
        &question.GrammarFocus,
583
2x
        &question.VocabularyDomain,
584
2x
        &question.Scenario,
585
2x
        &question.StyleModifier,
586
2x
        &question.DifficultyModifier,
587
2x
        &question.TimeContext,
588
2x
    )
589
2x
    if err != nil {
590
        return nil, contextutils.WrapError(err, "failed to query question")
591
    }
592

593
    // Parse JSON content
594
2x
    content := make(map[string]interface{})
595
2x
    if err := json.Unmarshal(contentJSON, &content); err != nil {
596
        return nil, contextutils.WrapError(err, "failed to parse question content")
597
    }
598
2x
    question.Content = content
599
2x

600
2x
    return &question, nil
601
}
602

603
// getSnippetByID retrieves a snippet by ID
604
func (s *WordOfTheDayService) getSnippetByID(ctx context.Context, snippetID int) (*models.Snippet, error) {
605
    query := `
606
        SELECT id, user_id, original_text, translated_text, source_language,
607
               target_language, question_id, section_id, story_id, context,
608
               difficulty_level, created_at, updated_at
609
        FROM snippets
610
        WHERE id = $1
611
    `
612

613
    var snippet models.Snippet
614
    err := s.db.QueryRowContext(ctx, query, snippetID).Scan(
615
        &snippet.ID,
616
        &snippet.UserID,
617
        &snippet.OriginalText,
618
        &snippet.TranslatedText,
619
        &snippet.SourceLanguage,
620
        &snippet.TargetLanguage,
621
        &snippet.QuestionID,
622
        &snippet.SectionID,
623
        &snippet.StoryID,
624
        &snippet.Context,
625
        &snippet.DifficultyLevel,
626
        &snippet.CreatedAt,
627
        &snippet.UpdatedAt,
628
    )
629
    if err != nil {
630
        return nil, contextutils.WrapError(err, "failed to query snippet")
631
    }
632

633
    return &snippet, nil
634
}
635


			
quizapp internal services worker_service.go
30.4%
Statements
197/647
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "fmt"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    contextutils "quizapp/internal/utils"
14

15
    "go.opentelemetry.io/otel/attribute"
16
)
17

18
// ErrSettingNotFound is returned when a setting is not found in the database
19
var ErrSettingNotFound = errors.New("setting not found")
20

21
// WorkerServiceInterface defines the interface for worker management operations
22
type WorkerServiceInterface interface {
23
    // Settings management
24
    GetSetting(ctx context.Context, key string) (string, error)
25
    SetSetting(ctx context.Context, key, value string) error
26
    IsGlobalPaused(ctx context.Context) (bool, error)
27
    SetGlobalPause(ctx context.Context, paused bool) error
28
    IsUserPaused(ctx context.Context, userID int) (bool, error)
29
    SetUserPause(ctx context.Context, userID int, paused bool) error
30

31
    // Status management
32
    UpdateWorkerStatus(ctx context.Context, instance string, status *models.WorkerStatus) error
33
    GetWorkerStatus(ctx context.Context, instance string) (*models.WorkerStatus, error)
34
    GetAllWorkerStatuses(ctx context.Context) ([]models.WorkerStatus, error)
35
    UpdateHeartbeat(ctx context.Context, instance string) error
36
    IsWorkerHealthy(ctx context.Context, instance string) (bool, error)
37

38
    // Control operations
39
    PauseWorker(ctx context.Context, instance string) error
40
    ResumeWorker(ctx context.Context, instance string) error
41
    GetWorkerHealth(ctx context.Context) (map[string]interface{}, error)
42
    GetHighPriorityTopics(ctx context.Context, userID int, language, level, questionType string) ([]string, error)
43
    GetGapAnalysis(ctx context.Context, userID int, language, level, questionType string) (map[string]int, error)
44
    GetPriorityDistribution(ctx context.Context, userID int, language, level, questionType string) (map[string]int, error)
45

46
    // Notification management
47
    GetNotificationStats(ctx context.Context) (map[string]interface{}, error)
48
    GetNotificationErrors(ctx context.Context, page, pageSize int, errorType, notificationType, resolved string) ([]map[string]interface{}, map[string]interface{}, map[string]interface{}, error)
49
    GetUpcomingNotifications(ctx context.Context, page, pageSize int, notificationType, status, scheduledAfter, scheduledBefore string) ([]map[string]interface{}, map[string]interface{}, map[string]interface{}, error)
50
    GetSentNotifications(ctx context.Context, page, pageSize int, notificationType, status, sentAfter, sentBefore string) ([]map[string]interface{}, map[string]interface{}, map[string]interface{}, error)
51

52
    // Test methods for creating test data
53
    CreateTestSentNotification(ctx context.Context, userID int, notificationType, subject, templateName, status, errorMessage string) error
54
}
55

56
// WorkerService implements worker management operations
57
type WorkerService struct {
58
    db     *sql.DB
59
    logger *observability.Logger
60
}
61

62
// NewWorkerServiceWithLogger creates a new WorkerService instance with logger
63
14x
func NewWorkerServiceWithLogger(db *sql.DB, logger *observability.Logger) *WorkerService {
64
14x
    return &WorkerService{
65
14x
        db:     db,
66
14x
        logger: logger,
67
14x
    }
68
14x
}
69

70
// GetSetting retrieves a setting value by key
71
10x
func (s *WorkerService) GetSetting(ctx context.Context, key string) (result0 string, err error) {
72
10x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_setting", attribute.String("setting.key", key))
73
10x
    defer observability.FinishSpan(span, &err)
74
10x

75
10x
    // Validate key
76
10x
    if len(key) == 0 || len(strings.TrimSpace(key)) == 0 {
77
1x
        return "", contextutils.WrapErrorf(errors.New("invalid setting key"), "setting key cannot be empty")
78
1x
    }
79

80
9x
    var value string
81
9x
    err = s.db.QueryRowContext(ctx, `
82
9x
        SELECT setting_value FROM worker_settings WHERE setting_key = $1
83
9x
    `, key).Scan(&value)
84
9x
    if err != nil {
85
3x
        if err == sql.ErrNoRows {
86
2x
            s.logger.Debug(ctx, "Setting not found", map[string]interface{}{"setting_key": key})
87
2x
            return "", contextutils.WrapErrorf(ErrSettingNotFound, "%s", key)
88
2x
        }
89
1x
        s.logger.Error(ctx, "Failed to get setting", err, map[string]interface{}{"setting_key": key})
90
1x
        return "", contextutils.WrapErrorf(err, "failed to get setting %s", key)
91
    }
92

93
6x
    return value, nil
94
}
95

96
// SetSetting updates or creates a setting
97
10x
func (s *WorkerService) SetSetting(ctx context.Context, key, value string) (err error) {
98
10x
    ctx, span := observability.TraceWorkerFunction(ctx, "set_setting", attribute.String("setting.key", key))
99
10x
    defer observability.FinishSpan(span, &err)
100
10x

101
10x
    // Validate key
102
10x
    if len(key) == 0 || len(strings.TrimSpace(key)) == 0 {
103
1x
        return contextutils.WrapErrorf(errors.New("invalid setting key"), "setting key cannot be empty")
104
1x
    }
105

106
9x
    _, err = s.db.ExecContext(ctx, `
107
9x
        INSERT INTO worker_settings (setting_key, setting_value, updated_at)
108
9x
        VALUES ($1, $2, NOW())
109
9x
        ON CONFLICT (setting_key) DO UPDATE SET
110
9x
            setting_value = EXCLUDED.setting_value,
111
9x
            updated_at = EXCLUDED.updated_at
112
9x
    `, key, value)
113
9x
    if err != nil {
114
2x
        s.logger.Error(ctx, "Failed to set setting", err, map[string]interface{}{"setting_key": key, "setting_value": value})
115
2x
        return contextutils.WrapErrorf(err, "failed to set setting %s", key)
116
2x
    }
117

118
7x
    s.logger.Debug(ctx, "Setting updated", map[string]interface{}{"setting_key": key, "setting_value": value})
119
7x
    return nil
120
}
121

122
// IsGlobalPaused checks if the worker is globally paused
123
4x
func (s *WorkerService) IsGlobalPaused(ctx context.Context) (result0 bool, err error) {
124
4x
    ctx, span := observability.TraceWorkerFunction(ctx, "is_global_paused")
125
4x
    defer observability.FinishSpan(span, &err)
126
4x

127
4x
    var value string
128
4x
    value, err = s.GetSetting(ctx, "global_pause")
129
4x
    if err != nil {
130
        // If setting doesn't exist, default to false (not paused)
131
        if errors.Is(err, ErrSettingNotFound) {
132
            // Initialize the setting with default value
133
            if setErr := s.SetSetting(ctx, "global_pause", "false"); setErr != nil {
134
                s.logger.Error(ctx, "Failed to initialize global_pause setting", setErr, map[string]interface{}{})
135
                return false, contextutils.WrapError(setErr, "failed to initialize global_pause setting")
136
            }
137
            return false, nil
138
        }
139
        s.logger.Error(ctx, "Failed to check global pause status", err, map[string]interface{}{})
140
        return false, err
141
    }
142

143
4x
    paused := value == "true"
144
4x
    s.logger.Debug(ctx, "Global pause status checked", map[string]interface{}{"global_paused": paused})
145
4x
    return paused, nil
146
}
147

148
// SetGlobalPause sets the global pause state
149
3x
func (s *WorkerService) SetGlobalPause(ctx context.Context, paused bool) (err error) {
150
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "set_global_pause", attribute.Bool("paused", paused))
151
3x
    defer observability.FinishSpan(span, &err)
152
3x

153
3x
    value := "false"
154
3x
    if paused {
155
2x
        value = "true"
156
2x
    }
157

158
3x
    err = s.SetSetting(ctx, "global_pause", value)
159
3x
    if err != nil {
160
1x
        return err
161
1x
    }
162

163
2x
    s.logger.Info(ctx, "Global pause state updated", map[string]interface{}{"global_paused": paused})
164
2x
    return nil
165
}
166

167
// IsUserPaused checks if a specific user is paused
168
5x
func (s *WorkerService) IsUserPaused(ctx context.Context, userID int) (result0 bool, err error) {
169
5x
    ctx, span := observability.TraceWorkerFunction(ctx, "is_user_paused", observability.AttributeUserID(userID))
170
5x
    defer observability.FinishSpan(span, &err)
171
5x

172
5x
    key := fmt.Sprintf("user_pause_%d", userID)
173
5x
    var value string
174
5x
    err = s.db.QueryRowContext(ctx, `
175
5x
        SELECT setting_value FROM worker_settings WHERE setting_key = $1
176
5x
    `, key).Scan(&value)
177
5x
    if err != nil {
178
2x
        if err == sql.ErrNoRows {
179
2x
            // If setting doesn't exist, user is not paused (this is the default state)
180
2x
            s.logger.Debug(ctx, "User pause setting not found, defaulting to not paused", map[string]interface{}{"user_id": userID})
181
2x
            return false, nil
182
2x
        }
183
        s.logger.Error(ctx, "Failed to check user pause status", err, map[string]interface{}{"user_id": userID})
184
        return false, contextutils.WrapErrorf(err, "failed to check user pause status for user %d", userID)
185
    }
186

187
3x
    paused := value == "true"
188
3x
    s.logger.Debug(ctx, "User pause status checked", map[string]interface{}{"user_id": userID, "user_paused": paused})
189
3x
    return paused, nil
190
}
191

192
// SetUserPause sets the pause state for a specific user
193
3x
func (s *WorkerService) SetUserPause(ctx context.Context, userID int, paused bool) (err error) {
194
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "set_user_pause", observability.AttributeUserID(userID), attribute.Bool("paused", paused))
195
3x
    defer observability.FinishSpan(span, &err)
196
3x

197
3x
    key := fmt.Sprintf("user_pause_%d", userID)
198
3x
    value := "false"
199
3x
    if paused {
200
2x
        value = "true"
201
2x
    }
202

203
3x
    err = s.SetSetting(ctx, key, value)
204
3x
    if err != nil {
205
        return err
206
    }
207

208
3x
    s.logger.Info(ctx, "User pause state updated", map[string]interface{}{"user_id": userID, "user_paused": paused})
209
3x
    return nil
210
}
211

212
// UpdateWorkerStatus updates the worker status in the database
213
10x
func (s *WorkerService) UpdateWorkerStatus(ctx context.Context, instance string, status *models.WorkerStatus) (err error) {
214
10x
    activity := ""
215
10x
    if status.CurrentActivity.Valid {
216
2x
        activity = status.CurrentActivity.String
217
2x
    }
218

219
10x
    ctx, span := observability.TraceWorkerFunction(ctx, "update_worker_status",
220
10x
        attribute.String("worker.instance", instance),
221
10x
        attribute.Bool("worker.is_running", status.IsRunning),
222
10x
        attribute.Bool("worker.is_paused", status.IsPaused),
223
10x
        attribute.String("worker.activity", activity),
224
10x
    )
225
10x
    defer observability.FinishSpan(span, &err)
226
10x

227
10x
    _, err = s.db.ExecContext(ctx, `
228
10x
        INSERT INTO worker_status (
229
10x
            worker_instance, is_running, is_paused, current_activity,
230
10x
            last_heartbeat, last_run_start, last_run_finish, last_run_error,
231
10x
            total_questions_generated, total_runs, updated_at
232
10x
        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
233
10x
        ON CONFLICT (worker_instance) DO UPDATE SET
234
10x
            is_running = EXCLUDED.is_running,
235
10x
            is_paused = EXCLUDED.is_paused,
236
10x
            current_activity = EXCLUDED.current_activity,
237
10x
            last_heartbeat = EXCLUDED.last_heartbeat,
238
10x
            last_run_start = EXCLUDED.last_run_start,
239
10x
            last_run_finish = EXCLUDED.last_run_finish,
240
10x
            last_run_error = EXCLUDED.last_run_error,
241
10x
            total_questions_generated = EXCLUDED.total_questions_generated,
242
10x
            total_runs = EXCLUDED.total_runs,
243
10x
            updated_at = EXCLUDED.updated_at
244
10x
    `, instance, status.IsRunning, status.IsPaused, status.CurrentActivity,
245
10x
        status.LastHeartbeat, status.LastRunStart, status.LastRunFinish,
246
10x
        status.LastRunError, status.TotalQuestionsGenerated, status.TotalRuns)
247
10x
    if err != nil {
248
        s.logger.Error(ctx, "Failed to update worker status", err, map[string]interface{}{
249
            "worker_instance": instance,
250
            "is_running":      status.IsRunning,
251
            "is_paused":       status.IsPaused,
252
            "activity":        activity,
253
        })
254
        err = contextutils.WrapErrorf(err, "failed to update worker status for instance %s", instance)
255
        return err
256
    }
257

258
10x
    s.logger.Debug(ctx, "Worker status updated", map[string]interface{}{
259
10x
        "worker_instance": instance,
260
10x
        "is_running":      status.IsRunning,
261
10x
        "is_paused":       status.IsPaused,
262
10x
        "activity":        activity,
263
10x
    })
264
10x
    return nil
265
}
266

267
// GetWorkerStatus retrieves worker status by instance
268
6x
func (s *WorkerService) GetWorkerStatus(ctx context.Context, instance string) (result0 *models.WorkerStatus, err error) {
269
6x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_worker_status", attribute.String("worker.instance", instance))
270
6x
    defer observability.FinishSpan(span, &err)
271
6x

272
6x
    var status models.WorkerStatus
273
6x
    err = s.db.QueryRowContext(ctx, `
274
6x
        SELECT id, worker_instance, is_running, is_paused, current_activity,
275
6x
               last_heartbeat, last_run_start, last_run_finish, last_run_error,
276
6x
               total_questions_generated, total_runs, created_at, updated_at
277
6x
        FROM worker_status WHERE worker_instance = $1
278
6x
    `, instance).Scan(
279
6x
        &status.ID, &status.WorkerInstance, &status.IsRunning, &status.IsPaused,
280
6x
        &status.CurrentActivity, &status.LastHeartbeat, &status.LastRunStart,
281
6x
        &status.LastRunFinish, &status.LastRunError, &status.TotalQuestionsGenerated,
282
6x
        &status.TotalRuns, &status.CreatedAt, &status.UpdatedAt,
283
6x
    )
284
6x
    if err != nil {
285
1x
        if err == sql.ErrNoRows {
286
1x
            s.logger.Debug(ctx, "Worker status not found", map[string]interface{}{"worker_instance": instance})
287
1x
            return nil, contextutils.WrapErrorf(err, "worker status not found for instance %s", instance)
288
1x
        }
289
        s.logger.Error(ctx, "Failed to get worker status", err, map[string]interface{}{"worker_instance": instance})
290
        return nil, contextutils.WrapErrorf(err, "failed to get worker status for instance %s", instance)
291
    }
292

293
5x
    return &status, nil
294
}
295

296
// GetAllWorkerStatuses retrieves all worker statuses
297
2x
func (s *WorkerService) GetAllWorkerStatuses(ctx context.Context) (result0 []models.WorkerStatus, err error) {
298
2x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_all_worker_statuses")
299
2x
    defer observability.FinishSpan(span, &err)
300
2x

301
2x
    var rows *sql.Rows
302
2x
    rows, err = s.db.QueryContext(ctx, `
303
2x
        SELECT id, worker_instance, is_running, is_paused, current_activity,
304
2x
               last_heartbeat, last_run_start, last_run_finish, last_run_error,
305
2x
               total_questions_generated, total_runs, created_at, updated_at
306
2x
        FROM worker_status ORDER BY worker_instance
307
2x
    `)
308
2x
    if err != nil {
309
        s.logger.Error(ctx, "Failed to get all worker statuses", err, map[string]interface{}{})
310
        return nil, contextutils.WrapError(err, "failed to get all worker statuses")
311
    }
312
2x
    defer func() {
313
2x
        if err := rows.Close(); err != nil {
314
            s.logger.Error(ctx, "Failed to close rows", err, map[string]interface{}{})
315
        }
316
    }()
317

318
2x
    var statuses []models.WorkerStatus
319
2x
    for rows.Next() {
320
5x
        var status models.WorkerStatus
321
5x
        err = rows.Scan(
322
5x
            &status.ID, &status.WorkerInstance, &status.IsRunning, &status.IsPaused,
323
5x
            &status.CurrentActivity, &status.LastHeartbeat, &status.LastRunStart,
324
5x
            &status.LastRunFinish, &status.LastRunError, &status.TotalQuestionsGenerated,
325
5x
            &status.TotalRuns, &status.CreatedAt, &status.UpdatedAt,
326
5x
        )
327
5x
        if err != nil {
328
            s.logger.Error(ctx, "Failed to scan worker status row", err, map[string]interface{}{})
329
            return nil, contextutils.WrapError(err, "failed to scan worker status row")
330
        }
331
5x
        statuses = append(statuses, status)
332
    }
333

334
2x
    if err := rows.Err(); err != nil {
335
        s.logger.Error(ctx, "Error iterating worker status rows", err, map[string]interface{}{})
336
        return nil, contextutils.WrapError(err, "error iterating worker status rows")
337
    }
338

339
2x
    s.logger.Debug(ctx, "Retrieved all worker statuses", map[string]interface{}{"count": len(statuses)})
340
2x
    return statuses, nil
341
}
342

343
// UpdateHeartbeat updates the heartbeat for a worker instance
344
2x
func (s *WorkerService) UpdateHeartbeat(ctx context.Context, instance string) (err error) {
345
2x
    ctx, span := observability.TraceWorkerFunction(ctx, "update_heartbeat", attribute.String("worker.instance", instance))
346
2x
    defer observability.FinishSpan(span, &err)
347
2x

348
2x
    _, err = s.db.ExecContext(ctx, `
349
2x
        INSERT INTO worker_status (worker_instance, last_heartbeat, updated_at)
350
2x
        VALUES ($1, NOW(), NOW())
351
2x
        ON CONFLICT (worker_instance) DO UPDATE SET
352
2x
            last_heartbeat = EXCLUDED.last_heartbeat,
353
2x
            updated_at = EXCLUDED.updated_at
354
2x
    `, instance)
355
2x
    if err != nil {
356
        s.logger.Error(ctx, "Failed to update heartbeat", err, map[string]interface{}{"worker_instance": instance})
357
        return contextutils.WrapErrorf(err, "failed to update heartbeat for instance %s", instance)
358
    }
359

360
2x
    s.logger.Debug(ctx, "Heartbeat updated", map[string]interface{}{"worker_instance": instance})
361
2x
    return nil
362
}
363

364
// IsWorkerHealthy checks if a worker instance is healthy based on recent heartbeat
365
6x
func (s *WorkerService) IsWorkerHealthy(ctx context.Context, instance string) (result0 bool, err error) {
366
6x
    ctx, span := observability.TraceWorkerFunction(ctx, "is_worker_healthy", attribute.String("worker.instance", instance))
367
6x
    defer observability.FinishSpan(span, &err)
368
6x

369
6x
    var lastHeartbeat sql.NullTime
370
6x
    err = s.db.QueryRowContext(ctx, `
371
6x
        SELECT last_heartbeat FROM worker_status WHERE worker_instance = $1
372
6x
    `, instance).Scan(&lastHeartbeat)
373
6x
    if err != nil {
374
1x
        if err == sql.ErrNoRows {
375
1x
            s.logger.Debug(ctx, "Worker not found, considered unhealthy", map[string]interface{}{"worker_instance": instance})
376
1x
            return false, nil
377
1x
        }
378
        s.logger.Error(ctx, "Failed to check worker health", err, map[string]interface{}{"worker_instance": instance})
379
        return false, contextutils.WrapErrorf(err, "failed to check worker health for instance %s", instance)
380
    }
381

382
5x
    if !lastHeartbeat.Valid {
383
        s.logger.Debug(ctx, "Worker has no heartbeat, considered unhealthy", map[string]interface{}{"worker_instance": instance})
384
        return false, nil
385
    }
386

387
    // Consider worker healthy if heartbeat is within the last 5 minutes
388
5x
    healthy := time.Since(lastHeartbeat.Time) < 5*time.Minute
389
5x
    s.logger.Debug(ctx, "Worker health checked", map[string]interface{}{
390
5x
        "worker_instance": instance,
391
5x
        "healthy":         healthy,
392
5x
        "last_heartbeat":  lastHeartbeat.Time,
393
5x
        "time_since":      time.Since(lastHeartbeat.Time).String(),
394
5x
    })
395
5x
    return healthy, nil
396
}
397

398
// PauseWorker pauses a specific worker instance
399
3x
func (s *WorkerService) PauseWorker(ctx context.Context, instance string) (err error) {
400
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "pause_worker", attribute.String("worker.instance", instance))
401
3x
    defer observability.FinishSpan(span, &err)
402
3x

403
3x
    _, err = s.db.ExecContext(ctx, `
404
3x
        UPDATE worker_status SET is_paused = true, updated_at = NOW()
405
3x
        WHERE worker_instance = $1
406
3x
    `, instance)
407
3x
    if err != nil {
408
        s.logger.Error(ctx, "Failed to pause worker", err, map[string]interface{}{"worker_instance": instance})
409
        return contextutils.WrapErrorf(err, "failed to pause worker instance %s", instance)
410
    }
411

412
3x
    s.logger.Info(ctx, "Worker paused", map[string]interface{}{"worker_instance": instance})
413
3x
    return nil
414
}
415

416
// ResumeWorker resumes a specific worker instance
417
3x
func (s *WorkerService) ResumeWorker(ctx context.Context, instance string) (err error) {
418
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "resume_worker", attribute.String("worker.instance", instance))
419
3x
    defer observability.FinishSpan(span, &err)
420
3x

421
3x
    _, err = s.db.ExecContext(ctx, `
422
3x
        UPDATE worker_status SET is_paused = false, updated_at = NOW()
423
3x
        WHERE worker_instance = $1
424
3x
    `, instance)
425
3x
    if err != nil {
426
        s.logger.Error(ctx, "Failed to resume worker", err, map[string]interface{}{"worker_instance": instance})
427
        return contextutils.WrapErrorf(err, "failed to resume worker instance %s", instance)
428
    }
429

430
3x
    s.logger.Info(ctx, "Worker resumed", map[string]interface{}{"worker_instance": instance})
431
3x
    return nil
432
}
433

434
// GetWorkerHealth returns a map of worker health information
435
1x
func (s *WorkerService) GetWorkerHealth(ctx context.Context) (result0 map[string]interface{}, err error) {
436
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_worker_health")
437
1x
    defer observability.FinishSpan(span, &err)
438
1x

439
1x
    var statuses []models.WorkerStatus
440
1x
    statuses, err = s.GetAllWorkerStatuses(ctx)
441
1x
    if err != nil {
442
        return nil, err
443
    }
444

445
1x
    var globalPaused bool
446
1x
    globalPaused, err = s.IsGlobalPaused(ctx)
447
1x
    if err != nil {
448
        s.logger.Error(ctx, "Failed to get global pause state", err, map[string]interface{}{})
449
        globalPaused = false // Default to false if we can't get the state
450
    }
451

452
1x
    health := make(map[string]interface{})
453
1x
    workerInstances := make([]map[string]interface{}, 0)
454
1x
    healthyCount := 0
455
1x
    totalCount := len(statuses)
456
1x

457
1x
    for _, status := range statuses {
458
3x
        healthy, err := s.IsWorkerHealthy(ctx, status.WorkerInstance)
459
3x
        if err != nil {
460
            s.logger.Error(ctx, "Failed to check health for worker", err, map[string]interface{}{"worker_instance": status.WorkerInstance})
461
            continue
462
        }
463

464
3x
        if healthy {
465
3x
            healthyCount++
466
3x
        }
467

468
        // Convert sql.NullString to string for last_run_error
469
3x
        var lastRunError string
470
3x
        if status.LastRunError.Valid {
471
            lastRunError = status.LastRunError.String
472
        }
473

474
3x
        workerInstance := map[string]interface{}{
475
3x
            "worker_instance":           status.WorkerInstance,
476
3x
            "healthy":                   healthy,
477
3x
            "is_running":                status.IsRunning,
478
3x
            "is_paused":                 status.IsPaused,
479
3x
            "last_heartbeat":            status.LastHeartbeat,
480
3x
            "last_run_error":            lastRunError,
481
3x
            "total_questions_generated": status.TotalQuestionsGenerated,
482
3x
            "total_runs":                status.TotalRuns,
483
3x
        }
484
3x
        workerInstances = append(workerInstances, workerInstance)
485
    }
486

487
    // Build comprehensive health summary
488
1x
    health["global_paused"] = globalPaused
489
1x
    health["worker_instances"] = workerInstances
490
1x
    health["total_count"] = totalCount
491
1x
    health["healthy_count"] = healthyCount
492
1x

493
1x
    s.logger.Debug(ctx, "Worker health retrieved", map[string]interface{}{"worker_count": len(health)})
494
1x
    return health, nil
495
}
496

497
// GetHighPriorityTopics returns topics with high average priority scores for a user
498
1x
func (s *WorkerService) GetHighPriorityTopics(ctx context.Context, userID int, language, level, questionType string) (result0 []string, err error) {
499
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_high_priority_topics",
500
1x
        observability.AttributeUserID(userID),
501
1x
        observability.AttributeLanguage(language),
502
1x
        observability.AttributeLevel(level),
503
1x
        attribute.String("question.type", questionType),
504
1x
    )
505
1x
    defer observability.FinishSpan(span, &err)
506
1x

507
1x
    query := `
508
1x
        SELECT q.topic_category, AVG(qps.priority_score) as avg_score
509
1x
        FROM questions q
510
1x
        JOIN user_questions uq ON q.id = uq.question_id
511
1x
        JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
512
1x
        WHERE uq.user_id = $1
513
1x
        AND q.language = $2
514
1x
        AND q.level = $3
515
1x
        AND q.type = $4
516
1x
        AND q.topic_category IS NOT NULL
517
1x
        AND q.topic_category != ''
518
1x
        GROUP BY q.topic_category
519
1x
        HAVING AVG(qps.priority_score) >= 7.0
520
1x
        ORDER BY avg_score DESC
521
1x
        LIMIT 5
522
1x
    `
523
1x
    rows, err := s.db.QueryContext(ctx, query, userID, language, level, questionType)
524
1x
    if err != nil {
525
        s.logger.Error(ctx, "Failed to get high priority topics", err, map[string]interface{}{
526
            "user_id": userID, "language": language, "level": level, "question_type": questionType,
527
        })
528
        return nil, contextutils.WrapError(err, "failed to get high priority topics")
529
    }
530
1x
    defer func() {
531
1x
        if err := rows.Close(); err != nil {
532
            s.logger.Error(ctx, "Failed to close rows", err, map[string]interface{}{})
533
        }
534
    }()
535
1x
    var topics []string
536
1x
    for rows.Next() {
537
        var topic string
538
        var avgScore float64
539
        if err := rows.Scan(&topic, &avgScore); err != nil {
540
            s.logger.Error(ctx, "Failed to scan high priority topics row", err, map[string]interface{}{})
541
            return nil, contextutils.WrapError(err, "failed to scan high priority topics row")
542
        }
543
        topics = append(topics, topic)
544
    }
545
1x
    if err := rows.Err(); err != nil {
546
        s.logger.Error(ctx, "Error iterating high priority topics rows", err, map[string]interface{}{})
547
        return nil, contextutils.WrapError(err, "error iterating high priority topics rows")
548
    }
549
1x
    s.logger.Debug(ctx, "Retrieved high priority topics", map[string]interface{}{"user_id": userID, "count": len(topics)})
550
1x
    return topics, nil
551
}
552

553
// GetGapAnalysis identifies areas with poor user performance (knowledge gaps)
554
1x
func (s *WorkerService) GetGapAnalysis(ctx context.Context, userID int, language, level, questionType string) (result0 map[string]int, err error) {
555
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_gap_analysis",
556
1x
        observability.AttributeUserID(userID),
557
1x
        observability.AttributeLanguage(language),
558
1x
        observability.AttributeLevel(level),
559
1x
        attribute.String("question.type", questionType),
560
1x
    )
561
1x
    defer observability.FinishSpan(span, &err)
562
1x

563
1x
    // Query to find areas where user has poor performance (low accuracy)
564
1x
    // This analyzes gaps in user's knowledge across topics and varieties
565
1x
    query := `
566
1x
        WITH user_performance AS (
567
1x
            SELECT
568
1x
                q.topic_category,
569
1x
                q.grammar_focus,
570
1x
                q.vocabulary_domain,
571
1x
                q.scenario,
572
1x
                COUNT(*) as total_questions,
573
1x
                COUNT(CASE WHEN ur.is_correct = true THEN 1 END) as correct_answers,
574
1x
                ROUND(
575
1x
                    COUNT(CASE WHEN ur.is_correct = true THEN 1 END)::decimal / COUNT(*)::decimal * 100, 2
576
1x
                ) as accuracy_percentage
577
1x
            FROM questions q
578
1x
            JOIN user_questions uq ON q.id = uq.question_id
579
1x
            LEFT JOIN user_responses ur ON q.id = ur.question_id AND ur.user_id = $1
580
1x
            WHERE uq.user_id = $1
581
1x
            AND q.language = $2
582
1x
            AND q.level = $3
583
1x
            AND q.type = $4
584
1x
            GROUP BY q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario
585
1x
        )
586
1x
        SELECT
587
1x
            COALESCE(topic_category, 'unknown') as area,
588
1x
            'topic' as gap_type,
589
1x
            total_questions,
590
1x
            accuracy_percentage
591
1x
        FROM user_performance
592
1x
        WHERE accuracy_percentage < 60 OR accuracy_percentage IS NULL
593
1x
        UNION ALL
594
1x
        SELECT
595
1x
            COALESCE(grammar_focus, 'unknown') as area,
596
1x
            'grammar' as gap_type,
597
1x
            total_questions,
598
1x
            accuracy_percentage
599
1x
        FROM user_performance
600
1x
        WHERE (accuracy_percentage < 60 OR accuracy_percentage IS NULL) AND grammar_focus IS NOT NULL
601
1x
        UNION ALL
602
1x
        SELECT
603
1x
            COALESCE(vocabulary_domain, 'unknown') as area,
604
1x
            'vocabulary' as gap_type,
605
1x
            total_questions,
606
1x
            accuracy_percentage
607
1x
        FROM user_performance
608
1x
        WHERE (accuracy_percentage < 60 OR accuracy_percentage IS NULL) AND vocabulary_domain IS NOT NULL
609
1x
        UNION ALL
610
1x
        SELECT
611
1x
            COALESCE(scenario, 'unknown') as area,
612
1x
            'scenario' as gap_type,
613
1x
            total_questions,
614
1x
            accuracy_percentage
615
1x
        FROM user_performance
616
1x
        WHERE (accuracy_percentage < 60 OR accuracy_percentage IS NULL) AND scenario IS NOT NULL
617
1x
        ORDER BY accuracy_percentage ASC, total_questions DESC
618
1x
    `
619
1x

620
1x
    rows, err := s.db.QueryContext(ctx, query, userID, language, level, questionType)
621
1x
    if err != nil {
622
        s.logger.Error(ctx, "Failed to get gap analysis", err, map[string]interface{}{
623
            "user_id": userID, "language": language, "level": level, "question_type": questionType,
624
        })
625
        return nil, contextutils.WrapError(err, "failed to get gap analysis")
626
    }
627
1x
    defer func() {
628
1x
        if err := rows.Close(); err != nil {
629
            s.logger.Error(ctx, "Failed to close rows", err, map[string]interface{}{})
630
        }
631
    }()
632

633
1x
    gaps := make(map[string]int)
634
1x
    for rows.Next() {
635
1x
        var area, gapType string
636
1x
        var totalQuestions int
637
1x
        var accuracyPercentage sql.NullFloat64
638
1x

639
1x
        if err := rows.Scan(&area, &gapType, &totalQuestions, &accuracyPercentage); err != nil {
640
            s.logger.Error(ctx, "Failed to scan gap analysis row", err, map[string]interface{}{})
641
            return nil, contextutils.WrapError(err, "failed to scan gap analysis row")
642
        }
643

644
        // Create a key that includes the gap type for better identification
645
1x
        key := fmt.Sprintf("%s_%s", gapType, area)
646
1x

647
1x
        // Use the number of questions as the gap severity indicator
648
1x
        // Areas with more questions but poor performance are bigger gaps
649
1x
        gaps[key] = totalQuestions
650
    }
651

652
1x
    if err := rows.Err(); err != nil {
653
        s.logger.Error(ctx, "Error iterating gap analysis rows", err, map[string]interface{}{})
654
        return nil, contextutils.WrapError(err, "error iterating gap analysis rows")
655
    }
656
1x
    s.logger.Debug(ctx, "Retrieved gap analysis", map[string]interface{}{"user_id": userID, "count": len(gaps)})
657
1x
    return gaps, nil
658
}
659

660
// GetPriorityDistribution returns the distribution of priority scores by topic
661
1x
func (s *WorkerService) GetPriorityDistribution(ctx context.Context, userID int, language, level, questionType string) (result0 map[string]int, err error) {
662
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_priority_distribution",
663
1x
        observability.AttributeUserID(userID),
664
1x
        observability.AttributeLanguage(language),
665
1x
        observability.AttributeLevel(level),
666
1x
        attribute.String("question.type", questionType),
667
1x
    )
668
1x
    defer observability.FinishSpan(span, &err)
669
1x

670
1x
    // Query to get priority score distribution by topic
671
1x
    query := `
672
1x
        SELECT q.topic_category, COUNT(*) as question_count
673
1x
        FROM questions q
674
1x
        JOIN user_questions uq ON q.id = uq.question_id
675
1x
        JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
676
1x
        WHERE uq.user_id = $1
677
1x
        AND q.language = $2
678
1x
        AND q.level = $3
679
1x
        AND q.type = $4
680
1x
        GROUP BY q.topic_category
681
1x
    `
682
1x

683
1x
    rows, err := s.db.QueryContext(ctx, query, userID, language, level, questionType)
684
1x
    if err != nil {
685
        s.logger.Error(ctx, "Failed to get priority distribution", err, map[string]interface{}{
686
            "user_id": userID, "language": language, "level": level, "question_type": questionType,
687
        })
688
        return nil, contextutils.WrapError(err, "failed to get priority distribution")
689
    }
690
1x
    defer func() {
691
1x
        if err := rows.Close(); err != nil {
692
            s.logger.Error(ctx, "Failed to close rows", err, map[string]interface{}{})
693
        }
694
    }()
695

696
1x
    distribution := make(map[string]int)
697
1x
    for rows.Next() {
698
        var topic string
699
        var count int
700
        if err := rows.Scan(&topic, &count); err != nil {
701
            s.logger.Error(ctx, "Failed to scan priority distribution row", err, map[string]interface{}{})
702
            return nil, contextutils.WrapError(err, "failed to scan priority distribution row")
703
        }
704
        distribution[topic] = count
705
    }
706

707
1x
    if err := rows.Err(); err != nil {
708
        s.logger.Error(ctx, "Error iterating priority distribution rows", err, map[string]interface{}{})
709
        return nil, contextutils.WrapError(err, "error iterating priority distribution rows")
710
    }
711
1x
    s.logger.Debug(ctx, "Retrieved priority distribution", map[string]interface{}{"user_id": userID, "count": len(distribution)})
712
1x
    return distribution, nil
713
}
714

715
// GetNotificationStats returns comprehensive notification statistics
716
func (s *WorkerService) GetNotificationStats(ctx context.Context) (result0 map[string]interface{}, err error) {
717
    ctx, span := observability.TraceWorkerFunction(ctx, "get_notification_stats")
718
    defer observability.FinishSpan(span, &err)
719

720
    // Get total notifications sent
721
    var totalSent int
722
    err = s.db.QueryRowContext(ctx, `
723
        SELECT COUNT(*) FROM sent_notifications WHERE status = 'sent'
724
    `).Scan(&totalSent)
725
    if err != nil {
726
        s.logger.Error(ctx, "Failed to get total notifications sent", err, map[string]interface{}{})
727
        return nil, contextutils.WrapError(err, "failed to get total notifications sent")
728
    }
729

730
    // Get total notifications failed
731
    var totalFailed int
732
    err = s.db.QueryRowContext(ctx, `
733
        SELECT COUNT(*) FROM sent_notifications WHERE status = 'failed'
734
    `).Scan(&totalFailed)
735
    if err != nil {
736
        s.logger.Error(ctx, "Failed to get total notifications failed", err, map[string]interface{}{})
737
        return nil, contextutils.WrapError(err, "failed to get total notifications failed")
738
    }
739

740
    // Calculate success rate
741
    var successRate float64
742
    if totalSent+totalFailed > 0 {
743
        successRate = float64(totalSent) / float64(totalSent+totalFailed)
744
    }
745

746
    // Get users with notifications enabled
747
    var usersWithNotifications int
748
    err = s.db.QueryRowContext(ctx, `
749
        SELECT COUNT(DISTINCT user_id) FROM user_learning_preferences WHERE daily_reminder_enabled = true
750
    `).Scan(&usersWithNotifications)
751
    if err != nil {
752
        s.logger.Error(ctx, "Failed to get users with notifications enabled", err, map[string]interface{}{})
753
        return nil, contextutils.WrapError(err, "failed to get users with notifications enabled")
754
    }
755

756
    // Get total users
757
    var totalUsers int
758
    err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&totalUsers)
759
    if err != nil {
760
        s.logger.Error(ctx, "Failed to get total users", err, map[string]interface{}{})
761
        return nil, contextutils.WrapError(err, "failed to get total users")
762
    }
763

764
    // Get notifications sent today
765
    var sentToday int
766
    err = s.db.QueryRowContext(ctx, `
767
        SELECT COUNT(*) FROM sent_notifications
768
        WHERE status = 'sent' AND DATE(sent_at) = CURRENT_DATE
769
    `).Scan(&sentToday)
770
    if err != nil {
771
        s.logger.Error(ctx, "Failed to get notifications sent today", err, map[string]interface{}{})
772
        return nil, contextutils.WrapError(err, "failed to get notifications sent today")
773
    }
774

775
    // Get notifications sent this week
776
    var sentThisWeek int
777
    err = s.db.QueryRowContext(ctx, `
778
        SELECT COUNT(*) FROM sent_notifications
779
        WHERE status = 'sent' AND sent_at >= DATE_TRUNC('week', CURRENT_DATE)
780
    `).Scan(&sentThisWeek)
781
    if err != nil {
782
        s.logger.Error(ctx, "Failed to get notifications sent this week", err, map[string]interface{}{})
783
        return nil, contextutils.WrapError(err, "failed to get notifications sent this week")
784
    }
785

786
    // Get upcoming notifications
787
    var upcomingNotifications int
788
    err = s.db.QueryRowContext(ctx, `
789
        SELECT COUNT(*) FROM upcoming_notifications WHERE status = 'pending'
790
    `).Scan(&upcomingNotifications)
791
    if err != nil {
792
        s.logger.Error(ctx, "Failed to get upcoming notifications", err, map[string]interface{}{})
793
        return nil, contextutils.WrapError(err, "failed to get upcoming notifications")
794
    }
795

796
    // Get unresolved errors
797
    var unresolvedErrors int
798
    err = s.db.QueryRowContext(ctx, `
799
        SELECT COUNT(*) FROM notification_errors WHERE resolved_at IS NULL
800
    `).Scan(&unresolvedErrors)
801
    if err != nil {
802
        s.logger.Error(ctx, "Failed to get unresolved errors", err, map[string]interface{}{})
803
        return nil, contextutils.WrapError(err, "failed to get unresolved errors")
804
    }
805

806
    // Get notifications by type
807
    notificationsByType := make(map[string]int)
808
    rows, err := s.db.QueryContext(ctx, `
809
        SELECT notification_type, COUNT(*)
810
        FROM sent_notifications
811
        WHERE status = 'sent'
812
        GROUP BY notification_type
813
    `)
814
    if err != nil {
815
        s.logger.Error(ctx, "Failed to get notifications by type", err, map[string]interface{}{})
816
        return nil, contextutils.WrapError(err, "failed to get notifications by type")
817
    }
818
    defer func() {
819
        if closeErr := rows.Close(); closeErr != nil {
820
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
821
        }
822
    }()
823

824
    for rows.Next() {
825
        var notificationType string
826
        var count int
827
        if err := rows.Scan(&notificationType, &count); err != nil {
828
            s.logger.Error(ctx, "Failed to scan notifications by type", err, map[string]interface{}{})
829
            return nil, contextutils.WrapError(err, "failed to scan notifications by type")
830
        }
831
        notificationsByType[notificationType] = count
832
    }
833

834
    // Get errors by type
835
    errorsByType := make(map[string]int)
836
    rows, err = s.db.QueryContext(ctx, `
837
        SELECT error_type, COUNT(*)
838
        FROM notification_errors
839
        GROUP BY error_type
840
    `)
841
    if err != nil {
842
        s.logger.Error(ctx, "Failed to get errors by type", err, map[string]interface{}{})
843
        return nil, contextutils.WrapError(err, "failed to get errors by type")
844
    }
845
    defer func() {
846
        if closeErr := rows.Close(); closeErr != nil {
847
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
848
        }
849
    }()
850

851
    for rows.Next() {
852
        var errorType string
853
        var count int
854
        if err := rows.Scan(&errorType, &count); err != nil {
855
            s.logger.Error(ctx, "Failed to scan errors by type", err, map[string]interface{}{})
856
            return nil, contextutils.WrapError(err, "failed to scan errors by type")
857
        }
858
        errorsByType[errorType] = count
859
    }
860

861
    stats := map[string]interface{}{
862
        "total_notifications_sent":         totalSent,
863
        "total_notifications_failed":       totalFailed,
864
        "success_rate":                     successRate,
865
        "users_with_notifications_enabled": usersWithNotifications,
866
        "total_users":                      totalUsers,
867
        "notifications_sent_today":         sentToday,
868
        "notifications_sent_this_week":     sentThisWeek,
869
        "notifications_by_type":            notificationsByType,
870
        "errors_by_type":                   errorsByType,
871
        "upcoming_notifications":           upcomingNotifications,
872
        "unresolved_errors":                unresolvedErrors,
873
    }
874

875
    s.logger.Debug(ctx, "Retrieved notification stats", map[string]interface{}{"stats": stats})
876
    return stats, nil
877
}
878

879
// GetNotificationErrors returns paginated notification errors with filtering
880
func (s *WorkerService) GetNotificationErrors(ctx context.Context, page, pageSize int, errorType, notificationType, resolved string) (result0 []map[string]interface{}, result1, result2 map[string]interface{}, err error) {
881
    ctx, span := observability.TraceWorkerFunction(ctx, "get_notification_errors",
882
        attribute.Int("page", page),
883
        attribute.Int("page_size", pageSize),
884
        attribute.String("error_type", errorType),
885
        attribute.String("notification_type", notificationType),
886
        attribute.String("resolved", resolved),
887
    )
888
    defer observability.FinishSpan(span, &err)
889

890
    // Build WHERE clause
891
    whereConditions := []string{}
892
    args := []interface{}{}
893
    argIndex := 1
894

895
    if errorType != "" {
896
        whereConditions = append(whereConditions, fmt.Sprintf("error_type = $%d", argIndex))
897
        args = append(args, errorType)
898
        argIndex++
899
    }
900

901
    if notificationType != "" {
902
        whereConditions = append(whereConditions, fmt.Sprintf("notification_type = $%d", argIndex))
903
        args = append(args, notificationType)
904
        argIndex++
905
    }
906

907
    switch resolved {
908
    case "true":
909
        whereConditions = append(whereConditions, "resolved_at IS NOT NULL")
910
    case "false":
911
        whereConditions = append(whereConditions, "resolved_at IS NULL")
912
    }
913

914
    whereClause := ""
915
    if len(whereConditions) > 0 {
916
        whereClause = "WHERE " + strings.Join(whereConditions, " AND ")
917
    }
918

919
    // Get total count
920
    var totalErrors int
921
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM notification_errors %s", whereClause)
922
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalErrors)
923
    if err != nil {
924
        s.logger.Error(ctx, "Failed to get total notification errors", err, map[string]interface{}{})
925
        return nil, nil, nil, contextutils.WrapError(err, "failed to get total notification errors")
926
    }
927

928
    // Calculate pagination
929
    offset := (page - 1) * pageSize
930
    totalPages := (totalErrors + pageSize - 1) / pageSize
931

932
    // Get errors with pagination
933
    args = append(args, pageSize, offset)
934
    query := fmt.Sprintf(`
935
        SELECT ne.id, ne.user_id, u.username, ne.notification_type, ne.error_type,
936
               ne.error_message, ne.email_address, ne.occurred_at, ne.resolved_at, ne.resolution_notes
937
        FROM notification_errors ne
938
        LEFT JOIN users u ON ne.user_id = u.id
939
        %s
940
        ORDER BY ne.occurred_at DESC
941
        LIMIT $%d OFFSET $%d
942
    `, whereClause, argIndex, argIndex+1)
943

944
    rows, err := s.db.QueryContext(ctx, query, args...)
945
    if err != nil {
946
        s.logger.Error(ctx, "Failed to get notification errors", err, map[string]interface{}{})
947
        return nil, nil, nil, contextutils.WrapError(err, "failed to get notification errors")
948
    }
949
    defer func() {
950
        if closeErr := rows.Close(); closeErr != nil {
951
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
952
        }
953
    }()
954

955
    var errors []map[string]interface{}
956
    for rows.Next() {
957
        var errorData map[string]interface{}
958
        var id int
959
        var userID sql.NullInt64
960
        var username sql.NullString
961
        var notificationType, errorType, errorMessage string
962
        var emailAddress sql.NullString
963
        var occurredAt time.Time
964
        var resolvedAt sql.NullTime
965
        var resolutionNotes sql.NullString
966

967
        err := rows.Scan(&id, &userID, &username, &notificationType, &errorType, &errorMessage, &emailAddress, &occurredAt, &resolvedAt, &resolutionNotes)
968
        if err != nil {
969
            s.logger.Error(ctx, "Failed to scan notification error", err, map[string]interface{}{})
970
            return nil, nil, nil, contextutils.WrapError(err, "failed to scan notification error")
971
        }
972

973
        errorData = map[string]interface{}{
974
            "id":                id,
975
            "notification_type": notificationType,
976
            "error_type":        errorType,
977
            "error_message":     errorMessage,
978
            "occurred_at":       occurredAt.Format(time.RFC3339),
979
        }
980

981
        if userID.Valid {
982
            errorData["user_id"] = userID.Int64
983
        }
984
        if username.Valid {
985
            errorData["username"] = username.String
986
        }
987
        if emailAddress.Valid {
988
            errorData["email_address"] = emailAddress.String
989
        }
990
        if resolvedAt.Valid {
991
            errorData["resolved_at"] = resolvedAt.Time.Format(time.RFC3339)
992
        }
993
        if resolutionNotes.Valid {
994
            errorData["resolution_notes"] = resolutionNotes.String
995
        }
996

997
        errors = append(errors, errorData)
998
    }
999

1000
    // Get stats
1001
    stats := map[string]interface{}{
1002
        "total_errors":      totalErrors,
1003
        "unresolved_errors": 0, // Will be calculated separately
1004
    }
1005

1006
    // Get unresolved errors count
1007
    var unresolvedCount int
1008
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM notification_errors WHERE resolved_at IS NULL").Scan(&unresolvedCount)
1009
    if err != nil {
1010
        s.logger.Error(ctx, "Failed to get unresolved errors count", err, map[string]interface{}{})
1011
    } else {
1012
        stats["unresolved_errors"] = unresolvedCount
1013
    }
1014

1015
    // Get errors by type
1016
    errorsByType := make(map[string]int)
1017
    rows, err = s.db.QueryContext(ctx, "SELECT error_type, COUNT(*) FROM notification_errors GROUP BY error_type")
1018
    if err != nil {
1019
        s.logger.Error(ctx, "Failed to get errors by type", err, map[string]interface{}{})
1020
    } else {
1021
        defer func() {
1022
            if closeErr := rows.Close(); closeErr != nil {
1023
                s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1024
            }
1025
        }()
1026
        for rows.Next() {
1027
            var errorType string
1028
            var count int
1029
            if err := rows.Scan(&errorType, &count); err != nil {
1030
                s.logger.Error(ctx, "Failed to scan errors by type", err, map[string]interface{}{})
1031
                continue
1032
            }
1033
            errorsByType[errorType] = count
1034
        }
1035
        stats["errors_by_type"] = errorsByType
1036
    }
1037

1038
    // Get errors by notification type
1039
    errorsByNotificationType := make(map[string]int)
1040
    rows, err = s.db.QueryContext(ctx, "SELECT notification_type, COUNT(*) FROM notification_errors GROUP BY notification_type")
1041
    if err != nil {
1042
        s.logger.Error(ctx, "Failed to get errors by notification type", err, map[string]interface{}{})
1043
    } else {
1044
        defer func() {
1045
            if closeErr := rows.Close(); closeErr != nil {
1046
                s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1047
            }
1048
        }()
1049
        for rows.Next() {
1050
            var notificationType string
1051
            var count int
1052
            if err := rows.Scan(&notificationType, &count); err != nil {
1053
                s.logger.Error(ctx, "Failed to scan errors by notification type", err, map[string]interface{}{})
1054
                continue
1055
            }
1056
            errorsByNotificationType[notificationType] = count
1057
        }
1058
        stats["errors_by_notification_type"] = errorsByNotificationType
1059
    }
1060

1061
    pagination := map[string]interface{}{
1062
        "page":        page,
1063
        "page_size":   pageSize,
1064
        "total":       totalErrors,
1065
        "total_pages": totalPages,
1066
    }
1067

1068
    s.logger.Debug(ctx, "Retrieved notification errors", map[string]interface{}{
1069
        "count": len(errors), "page": page, "total": totalErrors,
1070
    })
1071

1072
    return errors, pagination, stats, nil
1073
}
1074

1075
// GetUpcomingNotifications returns paginated upcoming notifications with filtering
1076
func (s *WorkerService) GetUpcomingNotifications(ctx context.Context, page, pageSize int, notificationType, status, scheduledAfter, scheduledBefore string) (result0 []map[string]interface{}, result1, result2 map[string]interface{}, err error) {
1077
    ctx, span := observability.TraceWorkerFunction(ctx, "get_upcoming_notifications",
1078
        attribute.Int("page", page),
1079
        attribute.Int("page_size", pageSize),
1080
        attribute.String("notification_type", notificationType),
1081
        attribute.String("status", status),
1082
        attribute.String("scheduled_after", scheduledAfter),
1083
        attribute.String("scheduled_before", scheduledBefore),
1084
    )
1085
    defer observability.FinishSpan(span, &err)
1086

1087
    // Build WHERE clause
1088
    whereConditions := []string{}
1089
    args := []interface{}{}
1090
    argIndex := 1
1091

1092
    if notificationType != "" {
1093
        whereConditions = append(whereConditions, fmt.Sprintf("notification_type = $%d", argIndex))
1094
        args = append(args, notificationType)
1095
        argIndex++
1096
    }
1097

1098
    if status != "" {
1099
        whereConditions = append(whereConditions, fmt.Sprintf("status = $%d", argIndex))
1100
        args = append(args, status)
1101
        argIndex++
1102
    }
1103

1104
    if scheduledAfter != "" {
1105
        whereConditions = append(whereConditions, fmt.Sprintf("scheduled_for >= $%d", argIndex))
1106
        args = append(args, scheduledAfter)
1107
        argIndex++
1108
    }
1109

1110
    if scheduledBefore != "" {
1111
        whereConditions = append(whereConditions, fmt.Sprintf("scheduled_for <= $%d", argIndex))
1112
        args = append(args, scheduledBefore)
1113
        argIndex++
1114
    }
1115

1116
    whereClause := ""
1117
    if len(whereConditions) > 0 {
1118
        whereClause = "WHERE " + strings.Join(whereConditions, " AND ")
1119
    }
1120

1121
    // Get total count
1122
    var totalNotifications int
1123
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM upcoming_notifications %s", whereClause)
1124
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalNotifications)
1125
    if err != nil {
1126
        s.logger.Error(ctx, "Failed to get total upcoming notifications", err, map[string]interface{}{})
1127
        return nil, nil, nil, contextutils.WrapError(err, "failed to get total upcoming notifications")
1128
    }
1129

1130
    // Calculate pagination
1131
    offset := (page - 1) * pageSize
1132
    totalPages := (totalNotifications + pageSize - 1) / pageSize
1133

1134
    // Get notifications with pagination
1135
    args = append(args, pageSize, offset)
1136
    query := fmt.Sprintf(`
1137
        SELECT un.id, un.user_id, u.username, u.email, un.notification_type,
1138
               un.scheduled_for, un.status, un.created_at
1139
        FROM upcoming_notifications un
1140
        LEFT JOIN users u ON un.user_id = u.id
1141
        %s
1142
        ORDER BY un.scheduled_for ASC
1143
        LIMIT $%d OFFSET $%d
1144
    `, whereClause, argIndex, argIndex+1)
1145

1146
    rows, err := s.db.QueryContext(ctx, query, args...)
1147
    if err != nil {
1148
        s.logger.Error(ctx, "Failed to get upcoming notifications", err, map[string]interface{}{})
1149
        return nil, nil, nil, contextutils.WrapError(err, "failed to get upcoming notifications")
1150
    }
1151
    defer func() {
1152
        if closeErr := rows.Close(); closeErr != nil {
1153
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1154
        }
1155
    }()
1156

1157
    var notifications []map[string]interface{}
1158
    for rows.Next() {
1159
        var notification map[string]interface{}
1160
        var id, userID int
1161
        var username, notificationType, status string
1162
        var scheduledFor, createdAt time.Time
1163
        var email sql.NullString
1164

1165
        err := rows.Scan(&id, &userID, &username, &email, &notificationType, &scheduledFor, &status, &createdAt)
1166
        if err != nil {
1167
            s.logger.Error(ctx, "Failed to scan upcoming notification", err, map[string]interface{}{})
1168
            return nil, nil, nil, contextutils.WrapError(err, "failed to scan upcoming notification")
1169
        }
1170

1171
        notification = map[string]interface{}{
1172
            "id":                id,
1173
            "user_id":           userID,
1174
            "username":          username,
1175
            "notification_type": notificationType,
1176
            "scheduled_for":     scheduledFor.Format(time.RFC3339),
1177
            "status":            status,
1178
            "created_at":        createdAt.Format(time.RFC3339),
1179
        }
1180

1181
        if email.Valid {
1182
            notification["email_address"] = email.String
1183
        } else {
1184
            notification["email_address"] = ""
1185
        }
1186

1187
        notifications = append(notifications, notification)
1188
    }
1189

1190
    // Get stats
1191
    stats := map[string]interface{}{
1192
        "total_pending":             0,
1193
        "total_scheduled_today":     0,
1194
        "total_scheduled_this_week": 0,
1195
    }
1196

1197
    // Get total pending
1198
    var totalPending int
1199
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM upcoming_notifications WHERE status = 'pending'").Scan(&totalPending)
1200
    if err != nil {
1201
        s.logger.Error(ctx, "Failed to get total pending", err, map[string]interface{}{})
1202
    } else {
1203
        stats["total_pending"] = totalPending
1204
    }
1205

1206
    // Get scheduled today
1207
    var scheduledToday int
1208
    err = s.db.QueryRowContext(ctx, `
1209
        SELECT COUNT(*) FROM upcoming_notifications
1210
        WHERE status = 'pending' AND DATE(scheduled_for) = CURRENT_DATE
1211
    `).Scan(&scheduledToday)
1212
    if err != nil {
1213
        s.logger.Error(ctx, "Failed to get scheduled today", err, map[string]interface{}{})
1214
    } else {
1215
        stats["total_scheduled_today"] = scheduledToday
1216
    }
1217

1218
    // Get scheduled this week
1219
    var scheduledThisWeek int
1220
    err = s.db.QueryRowContext(ctx, `
1221
        SELECT COUNT(*) FROM upcoming_notifications
1222
        WHERE status = 'pending' AND scheduled_for >= DATE_TRUNC('week', CURRENT_DATE)
1223
    `).Scan(&scheduledThisWeek)
1224
    if err != nil {
1225
        s.logger.Error(ctx, "Failed to get scheduled this week", err, map[string]interface{}{})
1226
    } else {
1227
        stats["total_scheduled_this_week"] = scheduledThisWeek
1228
    }
1229

1230
    // Get notifications by type
1231
    notificationsByType := make(map[string]int)
1232
    rows, err = s.db.QueryContext(ctx, "SELECT notification_type, COUNT(*) FROM upcoming_notifications GROUP BY notification_type")
1233
    if err != nil {
1234
        s.logger.Error(ctx, "Failed to get notifications by type", err, map[string]interface{}{})
1235
    } else {
1236
        defer func() {
1237
            if closeErr := rows.Close(); closeErr != nil {
1238
                s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1239
            }
1240
        }()
1241
        for rows.Next() {
1242
            var notificationType string
1243
            var count int
1244
            if err := rows.Scan(&notificationType, &count); err != nil {
1245
                s.logger.Error(ctx, "Failed to scan notifications by type", err, map[string]interface{}{})
1246
                continue
1247
            }
1248
            notificationsByType[notificationType] = count
1249
        }
1250
        stats["notifications_by_type"] = notificationsByType
1251
    }
1252

1253
    pagination := map[string]interface{}{
1254
        "page":        page,
1255
        "page_size":   pageSize,
1256
        "total":       totalNotifications,
1257
        "total_pages": totalPages,
1258
    }
1259

1260
    s.logger.Debug(ctx, "Retrieved upcoming notifications", map[string]interface{}{
1261
        "count": len(notifications), "page": page, "total": totalNotifications,
1262
    })
1263

1264
    return notifications, pagination, stats, nil
1265
}
1266

1267
// GetSentNotifications returns paginated sent notifications with filtering
1268
func (s *WorkerService) GetSentNotifications(ctx context.Context, page, pageSize int, notificationType, status, sentAfter, sentBefore string) (result0 []map[string]interface{}, result1, result2 map[string]interface{}, err error) {
1269
    ctx, span := observability.TraceWorkerFunction(ctx, "get_sent_notifications",
1270
        attribute.Int("page", page),
1271
        attribute.Int("page_size", pageSize),
1272
        attribute.String("notification_type", notificationType),
1273
        attribute.String("status", status),
1274
        attribute.String("sent_after", sentAfter),
1275
        attribute.String("sent_before", sentBefore),
1276
    )
1277
    defer observability.FinishSpan(span, &err)
1278

1279
    // Build WHERE clause
1280
    whereConditions := []string{}
1281
    args := []interface{}{}
1282
    argIndex := 1
1283

1284
    if notificationType != "" {
1285
        whereConditions = append(whereConditions, fmt.Sprintf("notification_type = $%d", argIndex))
1286
        args = append(args, notificationType)
1287
        argIndex++
1288
    }
1289

1290
    if status != "" {
1291
        whereConditions = append(whereConditions, fmt.Sprintf("status = $%d", argIndex))
1292
        args = append(args, status)
1293
        argIndex++
1294
    }
1295

1296
    if sentAfter != "" {
1297
        whereConditions = append(whereConditions, fmt.Sprintf("sent_at >= $%d", argIndex))
1298
        args = append(args, sentAfter)
1299
        argIndex++
1300
    }
1301

1302
    if sentBefore != "" {
1303
        whereConditions = append(whereConditions, fmt.Sprintf("sent_at <= $%d", argIndex))
1304
        args = append(args, sentBefore)
1305
        argIndex++
1306
    }
1307

1308
    whereClause := ""
1309
    if len(whereConditions) > 0 {
1310
        whereClause = "WHERE " + strings.Join(whereConditions, " AND ")
1311
    }
1312

1313
    // Get total count
1314
    var totalNotifications int
1315
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM sent_notifications %s", whereClause)
1316
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalNotifications)
1317
    if err != nil {
1318
        s.logger.Error(ctx, "Failed to get total sent notifications", err, map[string]interface{}{})
1319
        return nil, nil, nil, contextutils.WrapError(err, "failed to get total sent notifications")
1320
    }
1321

1322
    // Calculate pagination
1323
    offset := (page - 1) * pageSize
1324
    totalPages := (totalNotifications + pageSize - 1) / pageSize
1325

1326
    // Get notifications with pagination
1327
    args = append(args, pageSize, offset)
1328
    query := fmt.Sprintf(`
1329
        SELECT sn.id, sn.user_id, u.username, u.email, sn.notification_type,
1330
               sn.subject, sn.template_name, sn.sent_at, sn.status, sn.error_message, sn.retry_count
1331
        FROM sent_notifications sn
1332
        LEFT JOIN users u ON sn.user_id = u.id
1333
        %s
1334
        ORDER BY sn.sent_at DESC
1335
        LIMIT $%d OFFSET $%d
1336
    `, whereClause, argIndex, argIndex+1)
1337

1338
    rows, err := s.db.QueryContext(ctx, query, args...)
1339
    if err != nil {
1340
        s.logger.Error(ctx, "Failed to get sent notifications", err, map[string]interface{}{})
1341
        return nil, nil, nil, contextutils.WrapError(err, "failed to get sent notifications")
1342
    }
1343
    defer func() {
1344
        if closeErr := rows.Close(); closeErr != nil {
1345
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1346
        }
1347
    }()
1348

1349
    var notifications []map[string]interface{}
1350
    for rows.Next() {
1351
        var notification map[string]interface{}
1352
        var id, userID int
1353
        var username, notificationType, subject, templateName, status string
1354
        var sentAt time.Time
1355
        var errorMessage sql.NullString
1356
        var retryCount int
1357
        var email sql.NullString
1358

1359
        err := rows.Scan(&id, &userID, &username, &email, &notificationType, &subject, &templateName, &sentAt, &status, &errorMessage, &retryCount)
1360
        if err != nil {
1361
            s.logger.Error(ctx, "Failed to scan sent notification", err, map[string]interface{}{})
1362
            return nil, nil, nil, contextutils.WrapError(err, "failed to scan sent notification")
1363
        }
1364

1365
        notification = map[string]interface{}{
1366
            "id":                id,
1367
            "user_id":           userID,
1368
            "username":          username,
1369
            "notification_type": notificationType,
1370
            "subject":           subject,
1371
            "template_name":     templateName,
1372
            "sent_at":           sentAt.Format(time.RFC3339),
1373
            "status":            status,
1374
            "retry_count":       retryCount,
1375
        }
1376

1377
        if email.Valid {
1378
            notification["email_address"] = email.String
1379
        } else {
1380
            notification["email_address"] = ""
1381
        }
1382

1383
        if errorMessage.Valid {
1384
            notification["error_message"] = errorMessage.String
1385
        }
1386

1387
        notifications = append(notifications, notification)
1388
    }
1389

1390
    // Get stats
1391
    stats := map[string]interface{}{
1392
        "total_sent":     0,
1393
        "total_failed":   0,
1394
        "success_rate":   0.0,
1395
        "sent_today":     0,
1396
        "sent_this_week": 0,
1397
    }
1398

1399
    // Get total sent
1400
    var totalSent int
1401
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sent_notifications WHERE status = 'sent'").Scan(&totalSent)
1402
    if err != nil {
1403
        s.logger.Error(ctx, "Failed to get total sent", err, map[string]interface{}{})
1404
    } else {
1405
        stats["total_sent"] = totalSent
1406
    }
1407

1408
    // Get total failed
1409
    var totalFailed int
1410
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sent_notifications WHERE status = 'failed'").Scan(&totalFailed)
1411
    if err != nil {
1412
        s.logger.Error(ctx, "Failed to get total failed", err, map[string]interface{}{})
1413
    } else {
1414
        stats["total_failed"] = totalFailed
1415
    }
1416

1417
    // Calculate success rate
1418
    if totalSent+totalFailed > 0 {
1419
        stats["success_rate"] = float64(totalSent) / float64(totalSent+totalFailed)
1420
    }
1421

1422
    // Get sent today
1423
    var sentToday int
1424
    err = s.db.QueryRowContext(ctx, `
1425
        SELECT COUNT(*) FROM sent_notifications
1426
        WHERE status = 'sent' AND DATE(sent_at) = CURRENT_DATE
1427
    `).Scan(&sentToday)
1428
    if err != nil {
1429
        s.logger.Error(ctx, "Failed to get sent today", err, map[string]interface{}{})
1430
    } else {
1431
        stats["sent_today"] = sentToday
1432
    }
1433

1434
    // Get sent this week
1435
    var sentThisWeek int
1436
    err = s.db.QueryRowContext(ctx, `
1437
        SELECT COUNT(*) FROM sent_notifications
1438
        WHERE status = 'sent' AND sent_at >= DATE_TRUNC('week', CURRENT_DATE)
1439
    `).Scan(&sentThisWeek)
1440
    if err != nil {
1441
        s.logger.Error(ctx, "Failed to get sent this week", err, map[string]interface{}{})
1442
    } else {
1443
        stats["sent_this_week"] = sentThisWeek
1444
    }
1445

1446
    // Get notifications by type
1447
    notificationsByType := make(map[string]int)
1448
    rows, err = s.db.QueryContext(ctx, "SELECT notification_type, COUNT(*) FROM sent_notifications GROUP BY notification_type")
1449
    if err != nil {
1450
        s.logger.Error(ctx, "Failed to get notifications by type", err, map[string]interface{}{})
1451
    } else {
1452
        defer func() {
1453
            if closeErr := rows.Close(); closeErr != nil {
1454
                s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1455
            }
1456
        }()
1457
        for rows.Next() {
1458
            var notificationType string
1459
            var count int
1460
            if err := rows.Scan(&notificationType, &count); err != nil {
1461
                s.logger.Error(ctx, "Failed to scan notifications by type", err, map[string]interface{}{})
1462
                continue
1463
            }
1464
            notificationsByType[notificationType] = count
1465
        }
1466
        stats["notifications_by_type"] = notificationsByType
1467
    }
1468

1469
    pagination := map[string]interface{}{
1470
        "page":        page,
1471
        "page_size":   pageSize,
1472
        "total":       totalNotifications,
1473
        "total_pages": totalPages,
1474
    }
1475

1476
    s.logger.Debug(ctx, "Retrieved sent notifications", map[string]interface{}{
1477
        "count": len(notifications), "page": page, "total": totalNotifications,
1478
    })
1479

1480
    return notifications, pagination, stats, nil
1481
}
1482

1483
// CreateTestSentNotification creates a test sent notification for testing purposes
1484
func (s *WorkerService) CreateTestSentNotification(ctx context.Context, userID int, notificationType, subject, templateName, status, errorMessage string) error {
1485
    ctx, span := observability.TraceWorkerFunction(ctx, "create_test_sent_notification",
1486
        attribute.Int("user.id", userID),
1487
        attribute.String("notification.type", notificationType),
1488
        attribute.String("notification.status", status),
1489
    )
1490
    defer span.End()
1491

1492
    query := `
1493
        INSERT INTO sent_notifications (user_id, notification_type, subject, template_name, sent_at, status, error_message)
1494
        VALUES ($1, $2, $3, $4, $5, $6, $7)
1495
    `
1496

1497
    _, err := s.db.ExecContext(ctx, query, userID, notificationType, subject, templateName, time.Now(), status, errorMessage)
1498
    if err != nil {
1499
        span.RecordError(err)
1500
        s.logger.Error(ctx, "Failed to create test sent notification", err, map[string]interface{}{
1501
            "user_id":           userID,
1502
            "notification_type": notificationType,
1503
            "status":            status,
1504
        })
1505
        return contextutils.WrapError(err, "failed to create test sent notification")
1506
    }
1507

1508
    s.logger.Info(ctx, "Created test sent notification", map[string]interface{}{
1509
        "user_id":           userID,
1510
        "notification_type": notificationType,
1511
        "status":            status,
1512
    })
1513

1514
    return nil
1515
}
1516


			
quizapp internal utils
56.8%
Statements
147/259
errors.go
82.4%
56/68
localization.go
67.5%
56/83
security.go
100.0%
5/5
time.go
54.7%
29/53
validation.go
2.0%
1/50
quizapp internal utils validation.go
82.4%
Statements
56/68
1
// Package contextutils provides error handling utilities and standardized error types
2
// for consistent error management across the quiz application.
3
package contextutils
4

5
import (
6
    "context"
7
    "fmt"
8
    "strings"
9
)
10

11
// ErrorCode represents a standardized error code for API responses
12
type ErrorCode string
13

14
const (
15
    // Database error codes
16

17
    // ErrorCodeDatabaseConnection indicates a database connection error
18
    ErrorCodeDatabaseConnection ErrorCode = "DATABASE_CONNECTION_ERROR"
19
    // ErrorCodeDatabaseQuery indicates a database query error
20
    ErrorCodeDatabaseQuery ErrorCode = "DATABASE_QUERY_ERROR"
21
    // ErrorCodeDatabaseTransaction indicates a database transaction error
22
    ErrorCodeDatabaseTransaction ErrorCode = "DATABASE_TRANSACTION_ERROR"
23
    // ErrorCodeRecordNotFound indicates that a requested record was not found
24
    ErrorCodeRecordNotFound ErrorCode = "RECORD_NOT_FOUND"
25
    // ErrorCodeRecordExists indicates that a record already exists (duplicate key)
26
    ErrorCodeRecordExists ErrorCode = "RECORD_ALREADY_EXISTS"
27
    // ErrorCodeForeignKeyViolation indicates a foreign key constraint violation
28
    ErrorCodeForeignKeyViolation ErrorCode = "FOREIGN_KEY_VIOLATION"
29

30
    // Validation error codes
31

32
    // ErrorCodeInvalidInput indicates that the provided input is invalid
33
    ErrorCodeInvalidInput ErrorCode = "INVALID_INPUT"
34
    // ErrorCodeMissingRequired indicates that a required field is missing
35
    ErrorCodeMissingRequired ErrorCode = "MISSING_REQUIRED_FIELD"
36
    // ErrorCodeInvalidFormat indicates that the input format is invalid
37
    ErrorCodeInvalidFormat ErrorCode = "INVALID_FORMAT"
38
    // ErrorCodeValidationFailed indicates that validation has failed
39
    ErrorCodeValidationFailed ErrorCode = "VALIDATION_FAILED"
40

41
    // Authentication error codes
42

43
    // ErrorCodeUnauthorized indicates that the user is not authorized
44
    ErrorCodeUnauthorized ErrorCode = "UNAUTHORIZED"
45
    // ErrorCodeForbidden indicates that the user is forbidden from accessing the resource
46
    ErrorCodeForbidden ErrorCode = "FORBIDDEN"
47
    // ErrorCodeInvalidCredentials indicates that the provided credentials are invalid
48
    ErrorCodeInvalidCredentials ErrorCode = "INVALID_CREDENTIALS"
49
    // ErrorCodeSessionExpired indicates that the user session has expired
50
    ErrorCodeSessionExpired ErrorCode = "SESSION_EXPIRED"
51

52
    // Service error codes
53

54
    // ErrorCodeServiceUnavailable indicates that the service is temporarily unavailable
55
    ErrorCodeServiceUnavailable ErrorCode = "SERVICE_UNAVAILABLE"
56
    // ErrorCodeTimeout indicates that a request has timed out
57
    ErrorCodeTimeout ErrorCode = "REQUEST_TIMEOUT"
58
    // ErrorCodeRateLimit indicates that the rate limit has been exceeded
59
    ErrorCodeRateLimit ErrorCode = "RATE_LIMIT_EXCEEDED"
60
    // ErrorCodeQuotaExceeded indicates that the usage quota has been exceeded
61
    ErrorCodeQuotaExceeded ErrorCode = "QUOTA_EXCEEDED"
62
    // ErrorCodeInternalError indicates an internal server error
63
    ErrorCodeInternalError ErrorCode = "INTERNAL_SERVER_ERROR"
64
    // ErrorCodeAssignmentNotFound indicates that a question assignment was not found
65
    ErrorCodeAssignmentNotFound ErrorCode = "ASSIGNMENT_NOT_FOUND"
66
    // ErrorCodeConflict indicates that an operation conflicts with the current state
67
    ErrorCodeConflict ErrorCode = "CONFLICT"
68

69
    // Question error codes
70

71
    // ErrorCodeTimestampMissingTimezone indicates that a timestamp is missing timezone information
72
    ErrorCodeTimestampMissingTimezone ErrorCode = "TIMESTAMP_MISSING_TIMEZONE"
73
    // ErrorCodeNoQuestionsAvailable indicates that no questions are available
74
    ErrorCodeNoQuestionsAvailable ErrorCode = "NO_QUESTIONS_AVAILABLE"
75
    // ErrorCodeQuestionAlreadyAnswered indicates that the question has already been answered
76
    ErrorCodeQuestionAlreadyAnswered ErrorCode = "QUESTION_ALREADY_ANSWERED"
77
    // ErrorCodeQuestionNotFound indicates that the requested question was not found
78
    ErrorCodeQuestionNotFound ErrorCode = "QUESTION_NOT_FOUND"
79
    // ErrorCodeInvalidAnswerIndex indicates that the answer index is invalid
80
    ErrorCodeInvalidAnswerIndex ErrorCode = "INVALID_ANSWER_INDEX"
81
    // ErrorCodeGenerationLimitReached indicates that the daily generation limit has been reached
82
    ErrorCodeGenerationLimitReached ErrorCode = "GENERATION_LIMIT_REACHED"
83

84
    // AI Service error codes
85

86
    // ErrorCodeAIProviderUnavailable indicates that the AI provider is unavailable
87
    ErrorCodeAIProviderUnavailable ErrorCode = "AI_PROVIDER_UNAVAILABLE"
88
    // ErrorCodeAIRequestFailed indicates that the AI request failed
89
    ErrorCodeAIRequestFailed ErrorCode = "AI_REQUEST_FAILED"
90
    // ErrorCodeAIResponseInvalid indicates that the AI response is invalid
91
    ErrorCodeAIResponseInvalid ErrorCode = "AI_RESPONSE_INVALID"
92
    // ErrorCodeAIConfigInvalid indicates that the AI configuration is invalid
93
    ErrorCodeAIConfigInvalid ErrorCode = "AI_CONFIG_INVALID"
94

95
    // OAuth error codes
96

97
    // ErrorCodeOAuthCodeExpired indicates that the OAuth authorization code has expired
98
    ErrorCodeOAuthCodeExpired ErrorCode = "OAUTH_CODE_EXPIRED"
99
    // ErrorCodeOAuthStateMismatch indicates that the OAuth state parameter does not match
100
    ErrorCodeOAuthStateMismatch ErrorCode = "OAUTH_STATE_MISMATCH"
101
    // ErrorCodeOAuthProviderError indicates an error from the OAuth provider
102
    ErrorCodeOAuthProviderError ErrorCode = "OAUTH_PROVIDER_ERROR"
103
)
104

105
// SeverityLevel represents the severity of an error for logging and monitoring
106
type SeverityLevel string
107

108
const (
109
    // SeverityDebug indicates debug-level errors for development
110
    SeverityDebug SeverityLevel = "debug"
111
    // SeverityInfo indicates informational errors
112
    SeverityInfo SeverityLevel = "info"
113
    // SeverityWarn indicates warning-level errors
114
    SeverityWarn SeverityLevel = "warn"
115
    // SeverityError indicates error-level issues
116
    SeverityError SeverityLevel = "error"
117
    // SeverityFatal indicates fatal errors that require immediate attention
118
    SeverityFatal SeverityLevel = "fatal"
119
)
120

121
// AppError represents a structured error with code, severity, and context
122
type AppError struct {
123
    Code     ErrorCode
124
    Severity SeverityLevel
125
    Message  string
126
    Details  string
127
    Cause    error
128
}
129

130
// Error implements the error interface
131
5x
func (e *AppError) Error() string {
132
5x
    if e.Details != "" {
133
1x
        return fmt.Sprintf("%s: %s - %s", e.Code, e.Message, e.Details)
134
1x
    }
135
4x
    return fmt.Sprintf("%s: %s", e.Code, e.Message)
136
}
137

138
// Unwrap returns the underlying cause error
139
2x
func (e *AppError) Unwrap() error {
140
2x
    return e.Cause
141
2x
}
142

143
// Is implements error comparison for errors.Is
144
5x
func (e *AppError) Is(target error) bool {
145
5x
    if appErr, ok := target.(*AppError); ok {
146
3x
        return e.Code == appErr.Code
147
3x
    }
148
2x
    return false
149
}
150

151
// Error types for consistent error handling with associated codes and severity
152
var (
153
    // Database errors
154
    ErrDatabaseConnection = &AppError{
155
        Code:     ErrorCodeDatabaseConnection,
156
        Severity: SeverityError,
157
        Message:  "Database connection failed",
158
    }
159

160
    ErrDatabaseQuery = &AppError{
161
        Code:     ErrorCodeDatabaseQuery,
162
        Severity: SeverityError,
163
        Message:  "Database query failed",
164
    }
165

166
    ErrDatabaseTransaction = &AppError{
167
        Code:     ErrorCodeDatabaseTransaction,
168
        Severity: SeverityError,
169
        Message:  "Database transaction failed",
170
    }
171

172
    ErrRecordNotFound = &AppError{
173
        Code:     ErrorCodeRecordNotFound,
174
        Severity: SeverityInfo,
175
        Message:  "Record not found",
176
    }
177

178
    ErrRecordExists = &AppError{
179
        Code:     ErrorCodeRecordExists,
180
        Severity: SeverityInfo,
181
        Message:  "Record already exists",
182
    }
183

184
    ErrForeignKeyViolation = &AppError{
185
        Code:     ErrorCodeForeignKeyViolation,
186
        Severity: SeverityError,
187
        Message:  "Foreign key constraint violation",
188
    }
189

190
    // Validation errors
191
    ErrInvalidInput = &AppError{
192
        Code:     ErrorCodeInvalidInput,
193
        Severity: SeverityWarn,
194
        Message:  "Invalid input",
195
    }
196

197
    ErrMissingRequired = &AppError{
198
        Code:     ErrorCodeMissingRequired,
199
        Severity: SeverityWarn,
200
        Message:  "Missing required field",
201
    }
202

203
    ErrInvalidFormat = &AppError{
204
        Code:     ErrorCodeInvalidFormat,
205
        Severity: SeverityWarn,
206
        Message:  "Invalid format",
207
    }
208

209
    ErrValidationFailed = &AppError{
210
        Code:     ErrorCodeValidationFailed,
211
        Severity: SeverityWarn,
212
        Message:  "Validation failed",
213
    }
214

215
    // Authentication errors
216
    ErrUnauthorized = &AppError{
217
        Code:     ErrorCodeUnauthorized,
218
        Severity: SeverityWarn,
219
        Message:  "Unauthorized",
220
    }
221

222
    ErrForbidden = &AppError{
223
        Code:     ErrorCodeForbidden,
224
        Severity: SeverityWarn,
225
        Message:  "Forbidden",
226
    }
227

228
    ErrInvalidCredentials = &AppError{
229
        Code:     ErrorCodeInvalidCredentials,
230
        Severity: SeverityWarn,
231
        Message:  "Invalid credentials",
232
    }
233

234
    ErrSessionExpired = &AppError{
235
        Code:     ErrorCodeSessionExpired,
236
        Severity: SeverityInfo,
237
        Message:  "Session expired",
238
    }
239

240
    // Service errors
241
    ErrServiceUnavailable = &AppError{
242
        Code:     ErrorCodeServiceUnavailable,
243
        Severity: SeverityError,
244
        Message:  "Service unavailable",
245
    }
246

247
    ErrTimeout = &AppError{
248
        Code:     ErrorCodeTimeout,
249
        Severity: SeverityWarn,
250
        Message:  "Request timeout",
251
    }
252

253
    ErrRateLimit = &AppError{
254
        Code:     ErrorCodeRateLimit,
255
        Severity: SeverityWarn,
256
        Message:  "Rate limit exceeded",
257
    }
258

259
    ErrQuotaExceeded = &AppError{
260
        Code:     ErrorCodeQuotaExceeded,
261
        Severity: SeverityWarn,
262
        Message:  "Usage quota exceeded",
263
    }
264

265
    ErrInternalError = &AppError{
266
        Code:     ErrorCodeInternalError,
267
        Severity: SeverityError,
268
        Message:  "Internal server error",
269
    }
270

271
    ErrAssignmentNotFound = &AppError{
272
        Code:     ErrorCodeAssignmentNotFound,
273
        Severity: SeverityInfo,
274
        Message:  "Assignment not found",
275
    }
276

277
    ErrConflict = &AppError{
278
        Code:     ErrorCodeConflict,
279
        Severity: SeverityWarn,
280
        Message:  "Operation conflicts with current state",
281
    }
282

283
    // Question errors
284
    ErrTimestampMissingTimezone = &AppError{
285
        Code:     ErrorCodeTimestampMissingTimezone,
286
        Severity: SeverityError,
287
        Message:  "Timestamp missing timezone",
288
    }
289

290
    ErrNoQuestionsAvailable = &AppError{
291
        Code:     ErrorCodeNoQuestionsAvailable,
292
        Severity: SeverityInfo,
293
        Message:  "No questions available for assignment",
294
    }
295

296
    ErrQuestionAlreadyAnswered = &AppError{
297
        Code:     ErrorCodeQuestionAlreadyAnswered,
298
        Severity: SeverityInfo,
299
        Message:  "Question already answered",
300
    }
301

302
    ErrQuestionNotFound = &AppError{
303
        Code:     ErrorCodeQuestionNotFound,
304
        Severity: SeverityInfo,
305
        Message:  "Question not found",
306
    }
307

308
    ErrInvalidAnswerIndex = &AppError{
309
        Code:     ErrorCodeInvalidAnswerIndex,
310
        Severity: SeverityWarn,
311
        Message:  "Invalid answer index",
312
    }
313

314
    ErrGenerationLimitReached = &AppError{
315
        Code:     ErrorCodeGenerationLimitReached,
316
        Severity: SeverityInfo,
317
        Message:  "Daily generation limit reached",
318
    }
319

320
    // AI Service errors
321
    ErrAIProviderUnavailable = &AppError{
322
        Code:     ErrorCodeAIProviderUnavailable,
323
        Severity: SeverityError,
324
        Message:  "AI provider unavailable",
325
    }
326

327
    ErrAIRequestFailed = &AppError{
328
        Code:     ErrorCodeAIRequestFailed,
329
        Severity: SeverityError,
330
        Message:  "AI request failed",
331
    }
332

333
    ErrAIResponseInvalid = &AppError{
334
        Code:     ErrorCodeAIResponseInvalid,
335
        Severity: SeverityError,
336
        Message:  "AI response invalid",
337
    }
338

339
    ErrAIConfigInvalid = &AppError{
340
        Code:     ErrorCodeAIConfigInvalid,
341
        Severity: SeverityError,
342
        Message:  "AI configuration invalid",
343
    }
344

345
    // OAuth errors
346
    ErrOAuthCodeExpired = &AppError{
347
        Code:     ErrorCodeOAuthCodeExpired,
348
        Severity: SeverityWarn,
349
        Message:  "OAuth code expired",
350
    }
351

352
    ErrOAuthStateMismatch = &AppError{
353
        Code:     ErrorCodeOAuthStateMismatch,
354
        Severity: SeverityError,
355
        Message:  "OAuth state mismatch",
356
    }
357

358
    ErrOAuthProviderError = &AppError{
359
        Code:     ErrorCodeOAuthProviderError,
360
        Severity: SeverityError,
361
        Message:  "OAuth provider error",
362
    }
363
)
364

365
// NewAppError creates a new AppError with the specified code, severity, message and details
366
1x
func NewAppError(code ErrorCode, severity SeverityLevel, message, details string) *AppError {
367
1x
    return &AppError{
368
1x
        Code:     code,
369
1x
        Severity: severity,
370
1x
        Message:  message,
371
1x
        Details:  details,
372
1x
    }
373
1x
}
374

375
// NewAppErrorWithCause creates a new AppError with an underlying cause
376
1x
func NewAppErrorWithCause(code ErrorCode, severity SeverityLevel, message, details string, cause error) *AppError {
377
1x
    return &AppError{
378
1x
        Code:     code,
379
1x
        Severity: severity,
380
1x
        Message:  message,
381
1x
        Details:  details,
382
1x
        Cause:    cause,
383
1x
    }
384
1x
}
385

386
// WrapError wraps an error with additional context, preserving AppError structure if possible
387
4x
func WrapError(err error, context string) error {
388
4x
    if err == nil {
389
1x
        return nil
390
1x
    }
391

392
    // If it's already an AppError, wrap it with additional details
393
3x
    if appErr, ok := err.(*AppError); ok {
394
1x
        return &AppError{
395
1x
            Code:     appErr.Code,
396
1x
            Severity: appErr.Severity,
397
1x
            Message:  context,
398
1x
            Details:  appErr.Error(),
399
1x
            Cause:    appErr,
400
1x
        }
401
1x
    }
402

403
    // For regular errors, create a generic internal error wrapper
404
2x
    return &AppError{
405
2x
        Code:     ErrorCodeInternalError,
406
2x
        Severity: SeverityError,
407
2x
        Message:  context,
408
2x
        Details:  err.Error(),
409
2x
        Cause:    err,
410
2x
    }
411
}
412

413
// WrapErrorf wraps an error with formatted context, preserving AppError structure if possible
414
3x
func WrapErrorf(err error, format string, args ...interface{}) error {
415
3x
    if err == nil {
416
        return nil
417
    }
418

419
    // Handle %w verb for error wrapping by using fmt.Errorf
420
3x
    if strings.Contains(format, "%w") {
421
2x
        // Use fmt.Errorf to properly handle %w verb
422
2x
        wrappedErr := fmt.Errorf(format, args...)
423
2x

424
2x
        // If it's already an AppError, wrap it with the formatted message
425
2x
        if appErr, ok := err.(*AppError); ok {
426
1x
            return &AppError{
427
1x
                Code:     appErr.Code,
428
1x
                Severity: appErr.Severity,
429
1x
                Message:  wrappedErr.Error(),
430
1x
                Details:  appErr.Error(),
431
1x
                Cause:    wrappedErr,
432
1x
            }
433
1x
        }
434

435
        // For regular errors, wrap with the formatted error
436
1x
        return &AppError{
437
1x
            Code:     ErrorCodeInternalError,
438
1x
            Severity: SeverityError,
439
1x
            Message:  wrappedErr.Error(),
440
1x
            Details:  err.Error(),
441
1x
            Cause:    wrappedErr,
442
1x
        }
443
    }
444

445
    // If it's already an AppError, wrap it with additional details
446
1x
    if appErr, ok := err.(*AppError); ok {
447
        context := fmt.Sprintf(format, args...)
448
        return &AppError{
449
            Code:     appErr.Code,
450
            Severity: appErr.Severity,
451
            Message:  context,
452
            Details:  appErr.Error(),
453
            Cause:    appErr,
454
        }
455
    }
456

457
    // For regular errors, create a generic internal error wrapper
458
1x
    context := fmt.Sprintf(format, args...)
459
1x
    return &AppError{
460
1x
        Code:     ErrorCodeInternalError,
461
1x
        Severity: SeverityError,
462
1x
        Message:  context,
463
1x
        Details:  err.Error(),
464
1x
        Cause:    err,
465
1x
    }
466
}
467

468
// ErrorWithContextf creates a new error with formatted context
469
1x
func ErrorWithContextf(format string, args ...interface{}) error {
470
1x
    return &AppError{
471
1x
        Code:     ErrorCodeInternalError,
472
1x
        Severity: SeverityError,
473
1x
        Message:  fmt.Sprintf(format, args...),
474
1x
    }
475
1x
}
476

477
// IsError checks if an error matches a specific AppError type
478
3x
func IsError(err error, target *AppError) bool {
479
3x
    if appErr, ok := err.(*AppError); ok {
480
2x
        return appErr.Code == target.Code
481
2x
    }
482
1x
    return false
483
}
484

485
// AsError attempts to convert an error to an AppError
486
2x
func AsError(err error, target **AppError) bool {
487
2x
    if appErr, ok := err.(*AppError); ok {
488
1x
        *target = appErr
489
1x
        return true
490
1x
    }
491
1x
    return false
492
}
493

494
// GetErrorCode returns the error code from an error if it's an AppError, otherwise returns a default code
495
2x
func GetErrorCode(err error) ErrorCode {
496
2x
    if appErr, ok := err.(*AppError); ok {
497
1x
        return appErr.Code
498
1x
    }
499
1x
    return ErrorCodeInternalError
500
}
501

502
// GetErrorSeverity returns the severity level from an error if it's an AppError, otherwise returns error
503
2x
func GetErrorSeverity(err error) SeverityLevel {
504
2x
    if appErr, ok := err.(*AppError); ok {
505
1x
        return appErr.Severity
506
1x
    }
507
1x
    return SeverityError
508
}
509

510
// IsRetryable determines if an error should be retried based on its type and severity
511
8x
func IsRetryable(err error) bool {
512
8x
    if appErr, ok := err.(*AppError); ok {
513
7x
        // Only retry certain types of errors that are likely transient
514
7x
        switch appErr.Code {
515
4x
        case ErrorCodeTimeout, ErrorCodeServiceUnavailable, ErrorCodeDatabaseConnection:
516
4x
            return appErr.Severity != SeverityFatal
517
        }
518
    }
519
4x
    return false
520
}
521

522
// GetErrorLocalizedMessage returns a localized message for the error
523
3x
func GetErrorLocalizedMessage(err error, locale string) string {
524
3x
    if appErr, ok := err.(*AppError); ok {
525
2x
        return GetLocalizedMessageWithDetails(appErr.Code, ParseLocale(locale), appErr.Details)
526
2x
    }
527
1x
    return "An error occurred"
528
}
529

530
// ToJSON converts an AppError to a JSON-serializable structure for API responses
531
2x
func (e *AppError) ToJSON() map[string]interface{} {
532
2x
    result := map[string]interface{}{
533
2x
        "code":     string(e.Code),
534
2x
        "message":  e.Message,
535
2x
        "severity": string(e.Severity),
536
2x
        "error":    e.Message, // Include error field for backward compatibility
537
2x
    }
538
2x

539
2x
    if e.Details != "" {
540
2x
        result["details"] = e.Details
541
2x
    }
542

543
    // Add retryable information
544
2x
    result["retryable"] = IsRetryable(e)
545
2x

546
2x
    if e.Cause != nil {
547
1x
        // Only include cause in debug mode or for certain error types
548
1x
        switch e.Severity {
549
        case SeverityError, SeverityFatal:
550
            result["cause"] = e.Cause.Error()
551
        }
552
    }
553

554
2x
    return result
555
}
556

557
// ToJSONWithLocale converts an AppError to a JSON-serializable structure with localized messages
558
1x
func (e *AppError) ToJSONWithLocale(locale string) map[string]interface{} {
559
1x
    result := e.ToJSON()
560
1x
    // Replace the message with localized version and update error field too
561
1x
    localizedMessage := GetLocalizedMessage(e.Code, ParseLocale(locale))
562
1x
    result["message"] = localizedMessage
563
1x
    result["error"] = localizedMessage // Keep error field in sync
564
1x
    return result
565
1x
}
566

567
// ContextKey represents a context key type for passing values through context
568
type ContextKey string
569

570
const (
571
    // UserIDKey is used to store user ID in context for usage tracking
572
    UserIDKey ContextKey = "userID"
573
    // APIKeyIDKey is used to store API key ID in context for usage tracking
574
    APIKeyIDKey ContextKey = "apiKeyID"
575
)
576

577
// GetUserIDFromContext extracts the user ID from context, returning 0 if not found
578
func GetUserIDFromContext(ctx context.Context) int {
579
    if userID, ok := ctx.Value(UserIDKey).(int); ok {
580
        return userID
581
    }
582
    return 0 // Default fallback
583
}
584

585
// GetAPIKeyIDFromContext extracts the API key ID from context, returning nil if not found
586
func GetAPIKeyIDFromContext(ctx context.Context) *int {
587
    if apiKeyID, ok := ctx.Value(APIKeyIDKey).(*int); ok {
588
        return apiKeyID
589
    }
590
    return nil // Default fallback
591
}
592

593
// WithUserID returns a new context with the user ID set
594
func WithUserID(ctx context.Context, userID int) context.Context {
595
    return context.WithValue(ctx, UserIDKey, userID)
596
}
597

598
// WithAPIKeyID returns a new context with the API key ID set
599
func WithAPIKeyID(ctx context.Context, apiKeyID int) context.Context {
600
    return context.WithValue(ctx, APIKeyIDKey, &apiKeyID)
601
}
602


			
quizapp internal utils validation.go
67.5%
Statements
56/83
1
package contextutils
2

3
import (
4
    "encoding/json"
5
    "fmt"
6
    "strings"
7
)
8

9
// Locale represents a language locale (e.g., "en", "es", "fr")
10
type Locale string
11

12
const (
13
    // LocaleEnglish represents English language
14
    LocaleEnglish Locale = "en"
15
    // LocaleSpanish represents Spanish language
16
    LocaleSpanish Locale = "es"
17
    // LocaleFrench represents French language
18
    LocaleFrench Locale = "fr"
19
    // LocaleGerman represents German language
20
    LocaleGerman Locale = "de"
21
    // LocaleItalian represents Italian language
22
    LocaleItalian Locale = "it"
23
)
24

25
// LocalizedMessages contains localized error messages for different locales
26
type LocalizedMessages struct {
27
    messages map[ErrorCode]map[Locale]string
28
}
29

30
// NewLocalizedMessages creates a new instance of localized messages
31
7x
func NewLocalizedMessages() *LocalizedMessages {
32
7x
    return &LocalizedMessages{
33
7x
        messages: make(map[ErrorCode]map[Locale]string),
34
7x
    }
35
7x
}
36

37
// AddMessage adds a localized message for a specific error code and locale
38
23x
func (lm *LocalizedMessages) AddMessage(code ErrorCode, locale Locale, message string) {
39
23x
    if lm.messages[code] == nil {
40
11x
        lm.messages[code] = make(map[Locale]string)
41
11x
    }
42
23x
    lm.messages[code][locale] = message
43
}
44

45
// GetMessage returns the localized message for an error code and locale
46
16x
func (lm *LocalizedMessages) GetMessage(code ErrorCode, locale Locale) string {
47
16x
    // Try to get the message for the specific locale
48
16x
    if localeMessages, exists := lm.messages[code]; exists {
49
15x
        if message, exists := localeMessages[locale]; exists {
50
12x
            return message
51
12x
        }
52

53
        // Fallback to English if the specific locale doesn't have a message
54
3x
        if message, exists := localeMessages[LocaleEnglish]; exists {
55
1x
            return message
56
1x
        }
57
    }
58

59
    // Fallback to a default message
60
3x
    return getDefaultMessage(code)
61
}
62

63
// GetMessageWithDetails returns a localized message with additional details
64
4x
func (lm *LocalizedMessages) GetMessageWithDetails(code ErrorCode, locale Locale, details string) string {
65
4x
    message := lm.GetMessage(code, locale)
66
4x
    if details != "" {
67
3x
        return fmt.Sprintf("%s: %s", message, details)
68
3x
    }
69
1x
    return message
70
}
71

72
// getDefaultMessage returns a default English message for error codes
73
8x
func getDefaultMessage(code ErrorCode) string {
74
8x
    switch code {
75
    case ErrorCodeDatabaseConnection:
76
        return "Database connection failed"
77
    case ErrorCodeDatabaseQuery:
78
        return "Database query failed"
79
    case ErrorCodeDatabaseTransaction:
80
        return "Database transaction failed"
81
1x
    case ErrorCodeRecordNotFound:
82
1x
        return "Record not found"
83
    case ErrorCodeRecordExists:
84
        return "Record already exists"
85
    case ErrorCodeForeignKeyViolation:
86
        return "Foreign key constraint violation"
87
3x
    case ErrorCodeInvalidInput:
88
3x
        return "Invalid input"
89
    case ErrorCodeMissingRequired:
90
        return "Missing required field"
91
    case ErrorCodeInvalidFormat:
92
        return "Invalid format"
93
    case ErrorCodeValidationFailed:
94
        return "Validation failed"
95
1x
    case ErrorCodeUnauthorized:
96
1x
        return "Unauthorized access"
97
    case ErrorCodeForbidden:
98
        return "Access forbidden"
99
    case ErrorCodeInvalidCredentials:
100
        return "Invalid credentials"
101
    case ErrorCodeSessionExpired:
102
        return "Session expired"
103
    case ErrorCodeServiceUnavailable:
104
        return "Service temporarily unavailable"
105
    case ErrorCodeTimeout:
106
        return "Request timeout"
107
    case ErrorCodeRateLimit:
108
        return "Rate limit exceeded"
109
1x
    case ErrorCodeInternalError:
110
1x
        return "Internal server error"
111
    case ErrorCodeAssignmentNotFound:
112
        return "Assignment not found"
113
    case ErrorCodeTimestampMissingTimezone:
114
        return "Timestamp missing timezone"
115
    case ErrorCodeNoQuestionsAvailable:
116
        return "No questions available"
117
    case ErrorCodeQuestionAlreadyAnswered:
118
        return "Question already answered"
119
    case ErrorCodeQuestionNotFound:
120
        return "Question not found"
121
    case ErrorCodeInvalidAnswerIndex:
122
        return "Invalid answer index"
123
    case ErrorCodeAIProviderUnavailable:
124
        return "AI service unavailable"
125
    case ErrorCodeAIRequestFailed:
126
        return "AI request failed"
127
    case ErrorCodeAIResponseInvalid:
128
        return "AI response invalid"
129
    case ErrorCodeAIConfigInvalid:
130
        return "AI configuration invalid"
131
    case ErrorCodeOAuthCodeExpired:
132
        return "OAuth code expired"
133
    case ErrorCodeOAuthStateMismatch:
134
        return "OAuth state mismatch"
135
    case ErrorCodeOAuthProviderError:
136
        return "OAuth provider error"
137
2x
    default:
138
2x
        return "An error occurred"
139
    }
140
}
141

142
// LoadMessagesFromJSON loads localized messages from a JSON structure
143
2x
func (lm *LocalizedMessages) LoadMessagesFromJSON(jsonData string) error {
144
2x
    var data map[string]map[string]string
145
2x
    if err := json.Unmarshal([]byte(jsonData), &data); err != nil {
146
1x
        return WrapError(err, "failed to parse localization JSON")
147
1x
    }
148

149
1x
    for codeStr, localeMessages := range data {
150
2x
        code := ErrorCode(codeStr)
151
2x
        for localeStr, message := range localeMessages {
152
4x
            locale := Locale(localeStr)
153
4x
            lm.AddMessage(code, locale, message)
154
4x
        }
155
    }
156

157
1x
    return nil
158
}
159

160
// GetSupportedLocales returns a list of supported locales
161
1x
func (lm *LocalizedMessages) GetSupportedLocales() []Locale {
162
1x
    locales := make(map[Locale]bool)
163
1x

164
1x
    for _, localeMessages := range lm.messages {
165
2x
        for locale := range localeMessages {
166
3x
            locales[locale] = true
167
3x
        }
168
    }
169

170
1x
    result := make([]Locale, 0, len(locales))
171
1x
    for locale := range locales {
172
3x
        result = append(result, locale)
173
3x
    }
174

175
1x
    return result
176
}
177

178
// ParseLocale parses a locale string (e.g., "en-US", "fr-CA") and returns the language part
179
10x
func ParseLocale(localeStr string) Locale {
180
10x
    // Handle locale formats like "en-US", "fr-CA", etc.
181
10x
    parts := strings.Split(localeStr, "-")
182
10x
    if len(parts) > 0 && parts[0] != "" {
183
9x
        return Locale(strings.ToLower(parts[0]))
184
9x
    }
185
1x
    return LocaleEnglish // Default fallback
186
}
187

188
// Global instance of localized messages
189
var globalLocalizedMessages = NewLocalizedMessages()
190

191
// init loads default localized messages
192
1x
func init() {
193
1x
    // Load some basic localized messages
194
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInvalidInput, LocaleSpanish, "Entrada invÃlida")
195
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInvalidInput, LocaleFrench, "EntrÃe invalide")
196
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInvalidInput, LocaleGerman, "UngÃltige Eingabe")
197
1x

198
1x
    globalLocalizedMessages.AddMessage(ErrorCodeRecordNotFound, LocaleSpanish, "Registro no encontrado")
199
1x
    globalLocalizedMessages.AddMessage(ErrorCodeRecordNotFound, LocaleFrench, "Enregistrement non trouvÃ")
200
1x
    globalLocalizedMessages.AddMessage(ErrorCodeRecordNotFound, LocaleGerman, "Datensatz nicht gefunden")
201
1x

202
1x
    globalLocalizedMessages.AddMessage(ErrorCodeUnauthorized, LocaleSpanish, "Acceso no autorizado")
203
1x
    globalLocalizedMessages.AddMessage(ErrorCodeUnauthorized, LocaleFrench, "AccÃs non autorisÃ")
204
1x
    globalLocalizedMessages.AddMessage(ErrorCodeUnauthorized, LocaleGerman, "Unbefugter Zugriff")
205
1x

206
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInternalError, LocaleSpanish, "Error interno del servidor")
207
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInternalError, LocaleFrench, "Erreur interne du serveur")
208
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInternalError, LocaleGerman, "Interner Serverfehler")
209
1x
}
210

211
// GetLocalizedMessage returns a localized error message using the global instance
212
5x
func GetLocalizedMessage(code ErrorCode, locale Locale) string {
213
5x
    return globalLocalizedMessages.GetMessage(code, locale)
214
5x
}
215

216
// GetLocalizedMessageWithDetails returns a localized error message with details
217
2x
func GetLocalizedMessageWithDetails(code ErrorCode, locale Locale, details string) string {
218
2x
    return globalLocalizedMessages.GetMessageWithDetails(code, locale, details)
219
2x
}
220

221
// SetGlobalLocalizedMessages sets the global localized messages instance
222
1x
func SetGlobalLocalizedMessages(messages *LocalizedMessages) {
223
1x
    globalLocalizedMessages = messages
224
1x
}
225


			
quizapp internal utils validation.go
100.0%
Statements
5/5
1
package contextutils
2

3
import (
4
    "strings"
5
)
6

7
// MaskAPIKey masks an API key for logging purposes to prevent exposure
8
// Returns a masked version that shows only first 4 and last 4 characters
9
17x
func MaskAPIKey(apiKey string) string {
10
17x
    if apiKey == "" {
11
1x
        return "[EMPTY]"
12
1x
    }
13

14
16x
    if len(apiKey) <= 8 {
15
3x
        return strings.Repeat("*", len(apiKey))
16
3x
    }
17

18
13x
    return apiKey[:4] + strings.Repeat("*", len(apiKey)-8) + apiKey[len(apiKey)-4:]
19
}
20


			
quizapp internal utils validation.go
54.7%
Statements
29/53
1
package contextutils
2

3
import (
4
    "context"
5
    "time"
6

7
    "quizapp/internal/models"
8
)
9

10
// ParseDateInUserTimezone parses a YYYY-MM-DD date string in the user's timezone.
11
// The userLookup function is injected to fetch the user (to avoid tight coupling and enable testing).
12
// Returns the parsed time (in the location), the effective timezone name (or "UTC" on fallback), and an error.
13
// If the date format is invalid, the returned error will be wrapped with the message "invalid date format".
14
func ParseDateInUserTimezone(
15
    ctx context.Context,
16
    userID int,
17
    dateStr string,
18
    userLookup func(context.Context, int) (*models.User, error),
19
1x
) (time.Time, string, error) {
20
1x
    user, err := userLookup(ctx, userID)
21
1x
    if err != nil {
22
        return time.Time{}, "", err
23
    }
24

25
1x
    timezone := "UTC"
26
1x
    if user != nil && user.Timezone.Valid && user.Timezone.String != "" {
27
1x
        timezone = user.Timezone.String
28
1x
    }
29

30
1x
    loc, err := time.LoadLocation(timezone)
31
1x
    if err != nil {
32
        // Fallback to UTC if invalid timezone
33
        loc = time.UTC
34
        timezone = "UTC"
35
    }
36

37
1x
    date, err := time.ParseInLocation("2006-01-02", dateStr, loc)
38
1x
    if err != nil {
39
        return time.Time{}, timezone, WrapError(err, "invalid date format")
40
    }
41

42
1x
    return date, timezone, nil
43
}
44

45
// ConvertTimeToUserLocation converts the provided time to the user's timezone.
46
// Returns the converted time and the effective timezone name (or "UTC" on fallback).
47
func ConvertTimeToUserLocation(
48
    ctx context.Context,
49
    userID int,
50
    t time.Time,
51
    userLookup func(context.Context, int) (*models.User, error),
52
) (time.Time, string, error) {
53
    user, err := userLookup(ctx, userID)
54
    if err != nil {
55
        return time.Time{}, "", err
56
    }
57

58
    timezone := "UTC"
59
    if user != nil && user.Timezone.Valid && user.Timezone.String != "" {
60
        timezone = user.Timezone.String
61
    }
62

63
    loc, err := time.LoadLocation(timezone)
64
    if err != nil {
65
        loc = time.UTC
66
        timezone = "UTC"
67
    }
68

69
    return t.In(loc), timezone, nil
70
}
71

72
// FormatTimeInUserTimezone formats the provided time in the user's timezone using the given layout.
73
// Returns the formatted string and the effective timezone name.
74
func FormatTimeInUserTimezone(
75
    ctx context.Context,
76
    userID int,
77
    t time.Time,
78
    layout string,
79
    userLookup func(context.Context, int) (*models.User, error),
80
1x
) (string, string, error) {
81
1x
    // If the stored timestamp is exactly midnight UTC with zero nanoseconds,
82
1x
    // it may be a date-only value (missing timezone). We only treat it as
83
1x
    // missing if the user has a configured timezone that is not UTC.
84
1x
    if t.Location() == time.UTC && t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 && t.Nanosecond() == 0 {
85
1x
        if userLookup != nil {
86
1x
            if u, err := userLookup(ctx, userID); err == nil && u != nil && u.Timezone.Valid && u.Timezone.String != "" && u.Timezone.String != "UTC" {
87
1x
                return "", "", ErrTimestampMissingTimezone
88
1x
            }
89
        }
90
    }
91

92
    tt, tz, err := ConvertTimeToUserLocation(ctx, userID, t, userLookup)
93
    if err != nil {
94
        return "", tz, err
95
    }
96
    res := tt.Format(layout)
97
    return res, tz, nil
98
}
99

100
// UserLocalDayRange returns the UTC start and end timestamps that cover the
101
// last `days` calendar days for the given user in their configured timezone.
102
// The range is [startUTC, endUTC) where startUTC is the start of the earliest
103
// local day at 00:00 and endUTC is the start of the day after "today" at 00:00
104
// in UTC. The userLookup function is used to fetch the user's timezone.
105
2x
func UserLocalDayRange(ctx context.Context, userID, days int, userLookup func(context.Context, int) (*models.User, error)) (time.Time, time.Time, string, error) {
106
2x
    if days <= 0 {
107
        days = 1
108
    }
109
2x
    user, err := userLookup(ctx, userID)
110
2x
    if err != nil {
111
        return time.Time{}, time.Time{}, "", err
112
    }
113

114
2x
    timezone := "UTC"
115
2x
    if user != nil && user.Timezone.Valid && user.Timezone.String != "" {
116
1x
        timezone = user.Timezone.String
117
1x
    }
118

119
2x
    loc, err := time.LoadLocation(timezone)
120
2x
    if err != nil {
121
        loc = time.UTC
122
        timezone = "UTC"
123
    }
124

125
2x
    now := time.Now().In(loc)
126
2x
    today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
127
2x
    startLocal := today.AddDate(0, 0, -(days - 1))
128
2x
    // start of the day after today
129
2x
    endLocal := today.Add(24 * time.Hour)
130
2x

131
2x
    startUTC := startLocal.UTC()
132
2x
    endUTC := endLocal.UTC()
133
2x
    return startUTC, endUTC, timezone, nil
134
}
135


			
quizapp internal utils validation.go
2.0%
Statements
1/50
1
package contextutils
2

3
import (
4
    "fmt"
5
    "strings"
6

7
    "github.com/go-playground/validator/v10"
8
)
9

10
var validate = validator.New()
11

12
// IsValidEmail checks if an email address is valid using go-playground/validator
13
14x
func IsValidEmail(email string) bool {
14
14x
    return validate.Var(email, "email") == nil
15
14x
}
16

17
// normalizeStringSlice normalizes a string slice from various input types
18
func normalizeStringSlice(raw interface{}) []string {
19
    switch v := raw.(type) {
20
    case []string:
21
        out := make([]string, 0, len(v))
22
        for _, s := range v {
23
            if strings.TrimSpace(s) != "" {
24
                out = append(out, s)
25
            }
26
        }
27
        return out
28
    case []interface{}:
29
        out := make([]string, 0, len(v))
30
        for _, item := range v {
31
            if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
32
                out = append(out, s)
33
            }
34
        }
35
        return out
36
    default:
37
        return nil
38
    }
39
}
40

41
// ExtractQuestionContent extracts question text and options from a content map
42
// It handles both flat content maps and nested content.content maps
43
// For question text, it checks "question" first, then "sentence" (for FillInBlank questions)
44
func ExtractQuestionContent(content map[string]interface{}) (questionText string, options []string) {
45
    if content == nil {
46
        return "", nil
47
    }
48

49
    nestedContent, _ := content["content"].(map[string]interface{})
50

51
    getString := func(key string) string {
52
        if v, ok := content[key].(string); ok && strings.TrimSpace(v) != "" {
53
            return v
54
        }
55
        if nestedContent != nil {
56
            if v, ok := nestedContent[key].(string); ok && strings.TrimSpace(v) != "" {
57
                return v
58
            }
59
        }
60
        return ""
61
    }
62

63
    // Check "question" first, then "sentence" (for FillInBlank questions)
64
    questionText = getString("question")
65
    if questionText == "" {
66
        questionText = getString("sentence")
67
    }
68

69
    getOptions := func() []string {
70
        if raw, ok := content["options"]; ok {
71
            return normalizeStringSlice(raw)
72
        }
73
        if nestedContent != nil {
74
            if raw, ok := nestedContent["options"]; ok {
75
                return normalizeStringSlice(raw)
76
            }
77
        }
78
        return nil
79
    }
80

81
    options = getOptions()
82
    return questionText, options
83
}
84

85
// ValidateQuestionContent validates that question content has required fields:
86
// - question text must be non-empty
87
// - options must have at least 4 items
88
// Returns an error if validation fails, nil if valid
89
// questionID is used for error messages but can be 0 if not available
90
func ValidateQuestionContent(content map[string]interface{}, questionID int) error {
91
    if content == nil {
92
        if questionID > 0 {
93
            return ErrorWithContextf("question %d: content is nil (missing 'question'/'sentence' field and 'options' field)", questionID)
94
        }
95
        return ErrorWithContextf("question content is nil (missing 'question'/'sentence' field and 'options' field)")
96
    }
97

98
    questionText, options := ExtractQuestionContent(content)
99

100
    // Build a list of missing fields for clearer error messages
101
    var missingFields []string
102
    if questionText == "" {
103
        missingFields = append(missingFields, "'question' or 'sentence'")
104
    }
105
    if len(options) < 4 {
106
        missingFields = append(missingFields, fmt.Sprintf("'options' (has %d, need at least 4)", len(options)))
107
    }
108

109
    if len(missingFields) > 0 {
110
        if questionID > 0 {
111
            return ErrorWithContextf("question %d: missing required field(s): %s", questionID, strings.Join(missingFields, ", "))
112
        }
113
        return ErrorWithContextf("question content missing required field(s): %s", strings.Join(missingFields, ", "))
114
    }
115

116
    return nil
117
}
118


			
quizapp internal worker
70.5%
Statements
674/956
worker.go
70.5%
674/956
quizapp internal worker worker.go
70.5%
Statements
674/956
1
// Package worker contains the background worker responsible for generating
2
// and maintaining daily question assignments, scheduling generation jobs,
3
// and reporting worker health. The worker runs independently of HTTP
4
// request handling and interacts with the database, AI providers, and
5
// other internal services to keep question queues primed for users.
6
package worker
7

8
import (
9
    "context"
10
    "database/sql"
11
    "encoding/json"
12
    "errors"
13
    "fmt"
14
    "math"
15
    "os"
16
    "strconv"
17
    "strings"
18
    "sync"
19
    "time"
20

21
    "quizapp/internal/config"
22
    "quizapp/internal/models"
23
    "quizapp/internal/observability"
24
    "quizapp/internal/services"
25
    "quizapp/internal/services/mailer"
26
    contextutils "quizapp/internal/utils"
27

28
    "go.opentelemetry.io/otel"
29
    "go.opentelemetry.io/otel/attribute"
30
    "go.opentelemetry.io/otel/trace"
31
)
32

33
// Status represents the current state of the worker
34
type Status struct {
35
    IsRunning       bool      `json:"is_running"`
36
    IsPaused        bool      `json:"is_paused"`
37
    CurrentActivity string    `json:"current_activity,omitempty"`
38
    LastRunStart    time.Time `json:"last_run_start"`
39
    LastRunFinish   time.Time `json:"last_run_finish"`
40
    LastRunError    string    `json:"last_run_error,omitempty"`
41
    NextRun         time.Time `json:"next_run"`
42
}
43

44
// RunRecord tracks individual worker runs
45
type RunRecord struct {
46
    StartTime time.Time     `json:"start_time"`
47
    EndTime   time.Time     `json:"end_time"`
48
    Duration  time.Duration `json:"duration"`
49
    Status    string        `json:"status"` // Success, Failure
50
    Details   string        `json:"details"`
51
}
52

53
// ActivityLog represents a single activity log entry
54
type ActivityLog struct {
55
    Timestamp time.Time `json:"timestamp"`
56
    Level     string    `json:"level"` // INFO, WARN, ERROR
57
    Message   string    `json:"message"`
58
    UserID    *int      `json:"user_id,omitempty"`
59
    Username  *string   `json:"username,omitempty"`
60
}
61

62
// UserFailureInfo tracks failure information for exponential backoff
63
type UserFailureInfo struct {
64
    ConsecutiveFailures int
65
    LastFailureTime     time.Time
66
    NextRetryTime       time.Time
67
}
68

69
// Config holds worker-specific configuration
70
type Config struct {
71
    StartWorkerPaused bool
72
    DailyHorizonDays  int
73
}
74

75
// Worker manages AI question generation in the background
76
type Worker struct {
77
    userService            services.UserServiceInterface
78
    questionService        services.QuestionServiceInterface
79
    aiService              services.AIServiceInterface
80
    learningService        services.LearningServiceInterface
81
    workerService          services.WorkerServiceInterface
82
    dailyQuestionService   services.DailyQuestionServiceInterface
83
    wordOfTheDayService    services.WordOfTheDayServiceInterface
84
    storyService           services.StoryServiceInterface
85
    emailService           mailer.Mailer
86
    hintService            services.GenerationHintServiceInterface
87
    translationCacheRepo   services.TranslationCacheRepository
88
    instance               string
89
    status                 Status
90
    history                []RunRecord
91
    activityLogs           []ActivityLog // Circular buffer for recent activity logs
92
    mu                     sync.RWMutex
93
    manualTrigger          chan bool
94
    cfg                    *config.Config
95
    workerCfg              Config
96
    logger                 *observability.Logger
97
    lastTranslationCleanup time.Time // Track last translation cache cleanup
98
    translationCleanupMu   sync.RWMutex
99

100
    // Track failures for exponential backoff
101
    userFailures map[int]*UserFailureInfo // userID -> failure info
102
    failureMu    sync.RWMutex             // mutex for failure tracking
103

104
    // Time function for testing - defaults to time.Now
105
    timeNow func() time.Time
106
    cancel  context.CancelFunc // Added for cleanup
107
}
108

109
// cleanupTranslationCache removes expired translation cache entries once per day
110
3x
func (w *Worker) cleanupTranslationCache(ctx context.Context) error {
111
3x
    ctx, span := otel.Tracer("worker").Start(ctx, "cleanupTranslationCache",
112
3x
        trace.WithAttributes(
113
3x
            attribute.String("worker.instance", w.instance),
114
3x
        ),
115
3x
    )
116
3x
    defer span.End()
117
3x

118
3x
    // Check if we've already cleaned up today
119
3x
    w.translationCleanupMu.Lock()
120
3x
    lastCleanup := w.lastTranslationCleanup
121
3x
    w.translationCleanupMu.Unlock()
122
3x

123
3x
    now := w.timeNow()
124
3x

125
3x
    // Only cleanup once per day (check if last cleanup was on a different day)
126
3x
    if !lastCleanup.IsZero() {
127
        lastCleanupDay := lastCleanup.Truncate(24 * time.Hour)
128
        todayDay := now.Truncate(24 * time.Hour)
129

130
        if lastCleanupDay.Equal(todayDay) {
131
            // Already cleaned up today
132
            span.SetAttributes(
133
                attribute.Bool("cleanup.skipped", true),
134
                attribute.String("cleanup.last_run", lastCleanup.Format(time.RFC3339)),
135
            )
136
            return nil
137
        }
138
    }
139

140
3x
    w.logger.Info(ctx, "Cleaning up expired translation cache entries", map[string]interface{}{
141
3x
        "last_cleanup": lastCleanup,
142
3x
    })
143
3x

144
3x
    count, err := w.translationCacheRepo.CleanupExpiredTranslations(ctx)
145
3x
    if err != nil {
146
        span.RecordError(err)
147
        span.SetAttributes(attribute.Bool("cleanup.success", false))
148
        return contextutils.WrapError(err, "failed to cleanup expired translation cache entries")
149
    }
150

151
    // Update last cleanup time
152
3x
    w.translationCleanupMu.Lock()
153
3x
    w.lastTranslationCleanup = now
154
3x
    w.translationCleanupMu.Unlock()
155
3x

156
3x
    span.SetAttributes(
157
3x
        attribute.Bool("cleanup.success", true),
158
3x
        attribute.Int64("cleanup.deleted_count", count),
159
3x
    )
160
3x

161
3x
    w.logger.Info(ctx, "Translation cache cleanup completed", map[string]interface{}{
162
3x
        "deleted_count": count,
163
3x
        "instance":      w.instance,
164
3x
    })
165
3x

166
3x
    return nil
167
}
168

169
// checkForDailyReminders checks if any users need daily reminder emails
170
11x
func (w *Worker) checkForDailyReminders(ctx context.Context) error {
171
11x
    ctx, span := otel.Tracer("worker").Start(ctx, "checkForDailyReminders",
172
11x
        trace.WithAttributes(
173
11x
            attribute.String("worker.instance", w.instance),
174
11x
            attribute.Bool("email.daily_reminder.enabled", w.cfg.Email.DailyReminder.Enabled),
175
11x
            attribute.Int("email.daily_reminder.hour", w.cfg.Email.DailyReminder.Hour),
176
11x
            attribute.Bool("email.enabled", w.cfg.Email.Enabled),
177
11x
        ),
178
11x
    )
179
11x
    defer span.End()
180
11x

181
11x
    if !w.cfg.Email.DailyReminder.Enabled {
182
1x
        w.logger.Info(ctx, "Daily reminders disabled, skipping", nil)
183
1x
        return nil
184
1x
    }
185

186
    // Get current time in UTC
187
9x
    now := w.timeNow().UTC()
188
9x
    currentHour := now.Hour()
189
9x

190
9x
    // Check if it's time to send reminders (default: 9 AM)
191
9x
    reminderHour := w.cfg.Email.DailyReminder.Hour
192
9x
    if currentHour != reminderHour {
193
5x
        span.SetAttributes(
194
5x
            attribute.Int("check.current_hour", currentHour),
195
5x
            attribute.Int("check.reminder_hour", reminderHour),
196
5x
            attribute.Bool("check.should_send", false),
197
5x
            attribute.String("check.reason", "wrong_hour"),
198
5x
        )
199
5x
        return nil
200
5x
    }
201

202
2x
    span.SetAttributes(
203
2x
        attribute.Int("check.current_hour", currentHour),
204
2x
        attribute.Int("check.reminder_hour", reminderHour),
205
2x
        attribute.Bool("check.should_send", true),
206
2x
    )
207
2x

208
2x
    w.logger.Info(ctx, "Checking for users needing daily reminders", map[string]interface{}{
209
2x
        "reminder_hour": reminderHour,
210
2x
    })
211
2x

212
2x
    // Get users who need daily reminders
213
2x
    users, err := w.getUsersNeedingDailyReminders(ctx)
214
2x
    if err != nil {
215
        span.RecordError(err)
216
        span.SetAttributes(
217
            attribute.Int("users.total", 0),
218
            attribute.Int("users.eligible", 0),
219
            attribute.Int("reminders.sent", 0),
220
        )
221
        w.logger.Error(ctx, "Failed to get users needing daily reminders", err, nil)
222
        return contextutils.WrapError(err, "failed to get users needing daily reminders")
223
    }
224

225
2x
    span.SetAttributes(
226
2x
        attribute.Int("users.total", len(users)),
227
2x
    )
228
2x

229
2x
    remindersSent := 0
230
2x
    failedReminders := 0
231
2x

232
2x
    for _, user := range users {
233
1x
        // Record the sent notification
234
1x
        subject := "Time for your daily quiz! ð"
235
1x
        status := "sent"
236
1x
        errorMsg := ""
237
1x

238
1x
        if err := w.emailService.SendDailyReminder(ctx, &user); err != nil {
239
            failedReminders++
240
            status = "failed"
241
            errorMsg = err.Error()
242
            w.logger.Error(ctx, "Failed to send daily reminder", err, map[string]interface{}{
243
                "user_id": user.ID,
244
                "email":   user.Email.String,
245
            })
246
        } else {
247
1x
            remindersSent++
248
1x
        }
249

250
        // Record the sent notification in the database
251
1x
        if err := w.emailService.RecordSentNotification(ctx, user.ID, "daily_reminder", subject, "daily_reminder", status, errorMsg); err != nil {
252
            w.logger.Error(ctx, "Failed to record sent notification", err, map[string]interface{}{
253
                "user_id": user.ID,
254
            })
255
        }
256

257
        // Update the last reminder sent timestamp for this user
258
1x
        if err := w.learningService.UpdateLastDailyReminderSent(ctx, user.ID); err != nil {
259
            w.logger.Error(ctx, "Failed to update last daily reminder sent timestamp", err, map[string]interface{}{
260
                "user_id": user.ID,
261
            })
262
            // Don't count this as a failed reminder since the email was sent successfully
263
        }
264
    }
265

266
2x
    span.SetAttributes(
267
2x
        attribute.Int("users.eligible", len(users)),
268
2x
        attribute.Int("reminders.sent", remindersSent),
269
2x
        attribute.Int("reminders.failed", failedReminders),
270
2x
        attribute.Float64("reminders.success_rate", float64(remindersSent)/float64(len(users))),
271
2x
    )
272
2x

273
2x
    w.logger.Info(ctx, "Daily reminders processed", map[string]interface{}{
274
2x
        "total_users":    len(users),
275
2x
        "reminders_sent": remindersSent,
276
2x
        "reminder_hour":  reminderHour,
277
2x
    })
278
2x

279
2x
    return nil
280
}
281

282
// getUsersNeedingDailyReminders returns users who should receive daily reminders
283
3x
func (w *Worker) getUsersNeedingDailyReminders(ctx context.Context) ([]models.User, error) {
284
3x
    ctx, span := otel.Tracer("worker").Start(ctx, "getUsersNeedingDailyReminders")
285
3x
    defer span.End()
286
3x

287
3x
    // Get all users and filter for those with email addresses and daily reminders enabled
288
3x
    users, err := w.userService.GetAllUsers(ctx)
289
3x
    if err != nil {
290
        span.RecordError(err)
291
        return nil, contextutils.WrapError(err, "failed to get users")
292
    }
293

294
3x
    var eligibleUsers []models.User
295
3x
    today := w.timeNow().UTC().Format("2006-01-02")
296
3x

297
3x
    for _, user := range users {
298
9x
        // Check if user has email address
299
9x
        if !user.Email.Valid || user.Email.String == "" {
300
2x
            continue
301
        }
302

303
        // Get user's learning preferences to check daily reminder setting
304
7x
        prefs, err := w.learningService.GetUserLearningPreferences(ctx, user.ID)
305
7x
        if err != nil {
306
            w.logger.Warn(ctx, "Failed to get user learning preferences for daily reminder check", map[string]interface{}{
307
                "user_id":  user.ID,
308
                "username": user.Username,
309
                "error":    err.Error(),
310
            })
311
            continue
312
        }
313

314
        // Check if daily reminders are enabled for this user
315
7x
        if prefs == nil || !prefs.DailyReminderEnabled {
316
3x
            continue
317
        }
318

319
        // Check if we've already sent a reminder today
320
4x
        if prefs.LastDailyReminderSent != nil {
321
2x
            lastReminderDate := prefs.LastDailyReminderSent.Format("2006-01-02")
322
2x
            if lastReminderDate == today {
323
2x
                continue
324
            }
325
        }
326

327
2x
        eligibleUsers = append(eligibleUsers, user)
328
    }
329

330
3x
    w.logger.Info(ctx, "Found users eligible for daily reminders", map[string]interface{}{
331
3x
        "total_users":    len(users),
332
3x
        "eligible_users": len(eligibleUsers),
333
3x
    })
334
3x

335
3x
    return eligibleUsers, nil
336
}
337

338
// checkForDailyQuestionAssignments assigns daily questions to all eligible users
339
// This runs independently of email reminders to ensure users get daily questions
340
// even if they have email reminders disabled
341
17x
func (w *Worker) checkForDailyQuestionAssignments(ctx context.Context) error {
342
17x
    ctx, span := observability.TraceWorkerFunction(ctx, "check_for_daily_question_assignments",
343
17x
        attribute.String("worker.instance", w.instance),
344
17x
    )
345
17x
    defer observability.FinishSpan(span, nil)
346
17x

347
17x
    w.logger.Info(ctx, "Checking for daily question assignments", map[string]interface{}{
348
17x
        "instance": w.instance,
349
17x
    })
350
17x

351
17x
    // Get users who are eligible for daily questions
352
17x
    users, err := w.getUsersEligibleForDailyQuestions(ctx)
353
17x
    if err != nil {
354
        span.RecordError(err)
355
        w.logger.Error(ctx, "Failed to get users eligible for daily questions", err, nil)
356
        return contextutils.WrapError(err, "failed to get users eligible for daily questions")
357
    }
358

359
17x
    if len(users) == 0 {
360
5x
        w.logger.Info(ctx, "No users eligible for daily question assignments", map[string]interface{}{
361
5x
            "instance": w.instance,
362
5x
        })
363
5x
        return nil
364
5x
    }
365

366
12x
    span.SetAttributes(
367
12x
        attribute.Int("users.total", len(users)),
368
12x
    )
369
12x

370
12x
    successfulAssignments := 0
371
12x
    failedAssignments := 0
372
12x

373
12x
    for _, user := range users {
374
16x
        // Get user's timezone, default to UTC if not set
375
16x
        timezone := "UTC"
376
16x
        if user.Timezone.Valid && user.Timezone.String != "" {
377
2x
            timezone = user.Timezone.String
378
2x
        }
379

380
        // Get today's date in the user's timezone
381
16x
        loc, err := time.LoadLocation(timezone)
382
16x
        if err != nil {
383
            w.logger.Warn(ctx, "Invalid timezone for user, using UTC", map[string]interface{}{
384
                "user_id":  user.ID,
385
                "username": user.Username,
386
                "timezone": timezone,
387
                "error":    err.Error(),
388
            })
389
            loc = time.UTC
390
        }
391

392
        // Get today's date in the user's timezone
393
16x
        now := w.timeNow().In(loc)
394
16x
        today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
395
16x

396
16x
        // Assign daily questions for dates in [today .. today+N]
397
16x
        horizon := w.workerCfg.DailyHorizonDays
398
16x
        if horizon <= 0 {
399
            // default to 2 days ahead when misconfigured or not set
400
            horizon = 2
401
        }
402

403
        // Ensure the worker horizon covers the configured avoid window so
404
        // that when future assignments are removed (e.g., after a correct
405
        // submission) the worker run will top up missing slots. Use server
406
        // config as the source of truth for the avoid window.
407
16x
        avoidDays := 7
408
16x
        if w.cfg != nil && w.cfg.Server.DailyRepeatAvoidDays > 0 {
409
2x
            avoidDays = w.cfg.Server.DailyRepeatAvoidDays
410
2x
        }
411
16x
        if horizon < avoidDays {
412
16x
            w.logger.Info(ctx, "Extending worker daily horizon to cover daily repeat avoid window", map[string]interface{}{
413
16x
                "old_horizon": horizon,
414
16x
                "new_horizon": avoidDays,
415
16x
                "user_id":     user.ID,
416
16x
            })
417
16x
            horizon = avoidDays
418
16x
        }
419
16x
        for d := 0; d <= horizon; d++ {
420
128x
            target := today.AddDate(0, 0, d)
421
128x
            // Assign daily questions for target date in user's timezone
422
128x
            if err := w.dailyQuestionService.AssignDailyQuestions(ctx, user.ID, target); err != nil {
423
16x
                failedAssignments++
424
16x
                w.logger.Error(ctx, "Failed to assign daily questions", err, map[string]interface{}{
425
16x
                    "user_id":  user.ID,
426
16x
                    "username": user.Username,
427
16x
                    "timezone": timezone,
428
16x
                    "date":     target.Format("2006-01-02"),
429
16x
                })
430
16x
            } else {
431
96x
                successfulAssignments++
432
96x
            }
433
        }
434
    }
435

436
12x
    span.SetAttributes(
437
12x
        attribute.Int("assignments.successful", successfulAssignments),
438
12x
        attribute.Int("assignments.failed", failedAssignments),
439
12x
    )
440
12x

441
12x
    return nil
442
}
443

444
// getUsersEligibleForDailyQuestions returns users who should receive daily questions
445
// This is independent of email reminder preferences
446
25x
func (w *Worker) getUsersEligibleForDailyQuestions(ctx context.Context) ([]models.User, error) {
447
25x
    ctx, span := otel.Tracer("worker").Start(ctx, "getUsersEligibleForDailyQuestions")
448
25x
    defer span.End()
449
25x

450
25x
    // Get all users
451
25x
    users, err := w.userService.GetAllUsers(ctx)
452
25x
    if err != nil {
453
1x
        span.RecordError(err)
454
1x
        return nil, contextutils.WrapError(err, "failed to get users")
455
1x
    }
456

457
23x
    var eligibleUsers []models.User
458
23x

459
23x
    for _, user := range users {
460
35x
        // Check if user has language and level preferences set
461
35x
        if !user.PreferredLanguage.Valid || user.PreferredLanguage.String == "" {
462
3x
            w.logger.Debug(ctx, "User missing preferred language, skipping daily question assignment", map[string]interface{}{
463
3x
                "user_id":  user.ID,
464
3x
                "username": user.Username,
465
3x
            })
466
3x
            continue
467
        }
468

469
29x
        if !user.CurrentLevel.Valid || user.CurrentLevel.String == "" {
470
3x
            w.logger.Debug(ctx, "User missing current level, skipping daily question assignment", map[string]interface{}{
471
3x
                "user_id":  user.ID,
472
3x
                "username": user.Username,
473
3x
            })
474
3x
            continue
475
        }
476

477
        // USers with AI disabled are not eligible for daily questions
478
23x
        if !user.AIEnabled.Valid || !user.AIEnabled.Bool {
479
1x
            w.logger.Debug(ctx, "User has AI disabled, skipping daily question assignment", map[string]interface{}{
480
1x
                "user_id":  user.ID,
481
1x
                "username": user.Username,
482
1x
            })
483
1x
            continue
484
        }
485

486
22x
        eligibleUsers = append(eligibleUsers, user)
487
    }
488

489
23x
    w.logger.Info(ctx, "Found users eligible for daily questions", map[string]interface{}{
490
23x
        "total_users":    len(users),
491
23x
        "eligible_users": len(eligibleUsers),
492
23x
    })
493
23x

494
23x
    return eligibleUsers, nil
495
}
496

497
// checkForWordOfTheDayAssignments assigns word of the day to all eligible users
498
3x
func (w *Worker) checkForWordOfTheDayAssignments(ctx context.Context) error {
499
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "check_for_word_of_the_day_assignments",
500
3x
        attribute.String("worker.instance", w.instance),
501
3x
    )
502
3x
    defer observability.FinishSpan(span, nil)
503
3x

504
3x
    w.logger.Info(ctx, "Checking for word of the day assignments", map[string]interface{}{
505
3x
        "instance": w.instance,
506
3x
    })
507
3x

508
3x
    // Get users who are eligible for word of the day
509
3x
    users, err := w.getUsersEligibleForWordOfTheDay(ctx)
510
3x
    if err != nil {
511
        span.RecordError(err)
512
        w.logger.Error(ctx, "Failed to get users eligible for word of the day", err, nil)
513
        return contextutils.WrapError(err, "failed to get users eligible for word of the day")
514
    }
515

516
3x
    if len(users) == 0 {
517
3x
        w.logger.Info(ctx, "No users eligible for word of the day assignments", map[string]interface{}{
518
3x
            "instance": w.instance,
519
3x
        })
520
3x
        return nil
521
3x
    }
522

523
    span.SetAttributes(
524
        attribute.Int("users.total", len(users)),
525
    )
526

527
    successfulAssignments := 0
528
    failedAssignments := 0
529

530
    for _, user := range users {
531
        // Get user's timezone, default to UTC if not set
532
        timezone := "UTC"
533
        if user.Timezone.Valid && user.Timezone.String != "" {
534
            timezone = user.Timezone.String
535
        }
536

537
        // Get today's date in the user's timezone
538
        loc, err := time.LoadLocation(timezone)
539
        if err != nil {
540
            w.logger.Warn(ctx, "Invalid timezone for user, using UTC", map[string]interface{}{
541
                "user_id":  user.ID,
542
                "username": user.Username,
543
                "timezone": timezone,
544
                "error":    err.Error(),
545
            })
546
            loc = time.UTC
547
        }
548

549
        // Get today's date in the user's timezone
550
        now := w.timeNow().In(loc)
551
        today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
552

553
        // Idempotent: fetch existing or create if missing
554
        _, err = w.wordOfTheDayService.GetWordOfTheDay(ctx, user.ID, today)
555
        if err != nil {
556
            // Treat no-available-word as a normal condition
557
            if errors.Is(err, services.ErrNoSuitableWord) {
558
                w.logger.Info(ctx, "No suitable word available for user today", map[string]interface{}{
559
                    "user_id":  user.ID,
560
                    "username": user.Username,
561
                    "timezone": timezone,
562
                    "date":     today.Format("2006-01-02"),
563
                })
564
                continue
565
            }
566
            failedAssignments++
567
            w.logger.Error(ctx, "Failed to assign word of the day", err, map[string]interface{}{
568
                "user_id":  user.ID,
569
                "username": user.Username,
570
                "timezone": timezone,
571
                "date":     today.Format("2006-01-02"),
572
            })
573
        } else {
574
            successfulAssignments++
575
        }
576
    }
577

578
    span.SetAttributes(
579
        attribute.Int("assignments.successful", successfulAssignments),
580
        attribute.Int("assignments.failed", failedAssignments),
581
    )
582

583
    return nil
584
}
585

586
// getUsersEligibleForWordOfTheDay returns users who should receive word of the day
587
3x
func (w *Worker) getUsersEligibleForWordOfTheDay(ctx context.Context) ([]models.User, error) {
588
3x
    ctx, span := otel.Tracer("worker").Start(ctx, "getUsersEligibleForWordOfTheDay")
589
3x
    defer span.End()
590
3x

591
3x
    // Get all users
592
3x
    users, err := w.userService.GetAllUsers(ctx)
593
3x
    if err != nil {
594
        span.RecordError(err)
595
        return nil, contextutils.WrapError(err, "failed to get users")
596
    }
597

598
3x
    var eligibleUsers []models.User
599
3x

600
3x
    for _, user := range users {
601
1x
        // Check if user has language and level preferences set
602
1x
        if !user.PreferredLanguage.Valid || user.PreferredLanguage.String == "" {
603
            continue
604
        }
605

606
1x
        if !user.CurrentLevel.Valid || user.CurrentLevel.String == "" {
607
            continue
608
        }
609

610
        // Skip users with AI disabled
611
1x
        if !user.AIEnabled.Valid || !user.AIEnabled.Bool {
612
1x
            continue
613
        }
614

615
        eligibleUsers = append(eligibleUsers, user)
616
    }
617

618
3x
    w.logger.Info(ctx, "Found users eligible for word of the day", map[string]interface{}{
619
3x
        "total_users":    len(users),
620
3x
        "eligible_users": len(eligibleUsers),
621
3x
    })
622
3x

623
3x
    return eligibleUsers, nil
624
}
625

626
// checkForWordOfTheDayEmails sends word of the day emails to eligible users
627
7x
func (w *Worker) checkForWordOfTheDayEmails(ctx context.Context) error {
628
7x
    ctx, span := observability.TraceWorkerFunction(ctx, "check_for_word_of_the_day_emails",
629
7x
        attribute.String("worker.instance", w.instance),
630
7x
    )
631
7x
    defer observability.FinishSpan(span, nil)
632
7x

633
7x
    if !w.cfg.Email.DailyReminder.Enabled {
634
        w.logger.Info(ctx, "Email disabled, skipping word of the day emails", nil)
635
        return nil
636
    }
637

638
    // Get current time in UTC
639
7x
    now := w.timeNow().UTC()
640
7x
    currentHour := now.Hour()
641
7x

642
7x
    // Send word of the day emails at the same hour as daily reminders (default: 9 AM)
643
7x
    reminderHour := w.cfg.Email.DailyReminder.Hour
644
7x
    if currentHour != reminderHour {
645
3x
        return nil
646
3x
    }
647

648
    // Get users who should receive word of the day emails
649
2x
    users, err := w.getUsersNeedingWordOfTheDayEmails(ctx)
650
2x
    if err != nil {
651
        span.RecordError(err)
652
        return contextutils.WrapError(err, "failed to get users needing word of the day emails")
653
    }
654

655
2x
    span.SetAttributes(
656
2x
        attribute.Int("users.total", len(users)),
657
2x
    )
658
2x

659
2x
    emailsSent := 0
660
2x
    failedEmails := 0
661
2x

662
2x
    for _, user := range users {
663
2x
        // Get user's timezone
664
2x
        timezone := "UTC"
665
2x
        if user.Timezone.Valid && user.Timezone.String != "" {
666
            timezone = user.Timezone.String
667
        }
668

669
2x
        loc, err := time.LoadLocation(timezone)
670
2x
        if err != nil {
671
            loc = time.UTC
672
        }
673

674
2x
        now := w.timeNow().In(loc)
675
2x
        today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
676
2x

677
2x
        // Get word of the day for today
678
2x
        word, err := w.wordOfTheDayService.GetWordOfTheDay(ctx, user.ID, today)
679
2x
        if err != nil {
680
            failedEmails++
681
            w.logger.Error(ctx, "Failed to get word of the day for email", err, map[string]interface{}{
682
                "user_id":  user.ID,
683
                "username": user.Username,
684
            })
685
            continue
686
        }
687

688
2x
        if word == nil {
689
            // No word available, skip
690
            continue
691
        }
692

693
        // Send email (convert mailer.Mailer to services.EmailServiceInterface)
694
2x
        emailSvc, ok := w.emailService.(services.EmailServiceInterface)
695
2x
        if !ok {
696
            w.logger.Warn(ctx, "Email service does not support word of the day emails", map[string]interface{}{
697
                "user_id": user.ID,
698
            })
699
            continue
700
        }
701

702
2x
        alreadySent, err := emailSvc.HasSentWordOfTheDayEmail(ctx, user.ID, today)
703
2x
        if err != nil {
704
            failedEmails++
705
            w.logger.Error(ctx, "Failed to check word of the day email history", err, map[string]interface{}{
706
                "user_id":  user.ID,
707
                "username": user.Username,
708
            })
709
            continue
710
        }
711

712
2x
        if alreadySent {
713
1x
            continue
714
        }
715

716
1x
        if err := emailSvc.SendWordOfTheDayEmail(ctx, user.ID, today, word); err != nil {
717
            failedEmails++
718
            w.logger.Error(ctx, "Failed to send word of the day email", err, map[string]interface{}{
719
                "user_id":  user.ID,
720
                "username": user.Username,
721
            })
722
        } else {
723
1x
            emailsSent++
724
1x
        }
725
    }
726

727
2x
    span.SetAttributes(
728
2x
        attribute.Int("emails.sent", emailsSent),
729
2x
        attribute.Int("emails.failed", failedEmails),
730
2x
    )
731
2x

732
2x
    return nil
733
}
734

735
// getUsersNeedingWordOfTheDayEmails returns users who should receive word of the day emails
736
2x
func (w *Worker) getUsersNeedingWordOfTheDayEmails(ctx context.Context) ([]models.User, error) {
737
2x
    ctx, span := otel.Tracer("worker").Start(ctx, "getUsersNeedingWordOfTheDayEmails")
738
2x
    defer span.End()
739
2x

740
2x
    // Get all users
741
2x
    users, err := w.userService.GetAllUsers(ctx)
742
2x
    if err != nil {
743
        span.RecordError(err)
744
        return nil, contextutils.WrapError(err, "failed to get users")
745
    }
746

747
2x
    var eligibleUsers []models.User
748
2x

749
2x
    for _, user := range users {
750
2x
        // Check if user has email address
751
2x
        if !user.Email.Valid || user.Email.String == "" {
752
            continue
753
        }
754

755
        // Check if word of the day emails are enabled for this user
756
2x
        if !user.WordOfDayEmailEnabled.Bool {
757
            continue
758
        }
759

760
2x
        eligibleUsers = append(eligibleUsers, user)
761
    }
762

763
2x
    w.logger.Info(ctx, "Found users eligible for word of the day emails", map[string]interface{}{
764
2x
        "total_users":    len(users),
765
2x
        "eligible_users": len(eligibleUsers),
766
2x
    })
767
2x

768
2x
    return eligibleUsers, nil
769
}
770

771
// NewWorker creates a new Worker instance
772
139x
func NewWorker(userService services.UserServiceInterface, questionService services.QuestionServiceInterface, aiService services.AIServiceInterface, learningService services.LearningServiceInterface, workerService services.WorkerServiceInterface, dailyQuestionService services.DailyQuestionServiceInterface, wordOfTheDayService services.WordOfTheDayServiceInterface, storyService services.StoryServiceInterface, emailService mailer.Mailer, hintService services.GenerationHintServiceInterface, translationCacheRepo services.TranslationCacheRepository, instance string, cfg *config.Config, logger *observability.Logger) *Worker {
773
139x
    if instance == "" {
774
1x
        instance = "default"
775
1x
    }
776

777
139x
    ctx, cancel := context.WithCancel(context.Background())
778
139x

779
139x
    // Prefer value from config file when set (>0). If not set, default to 1.
780
139x
    dailyHorizon := cfg.Server.DailyHorizonDays
781
139x
    if dailyHorizon <= 0 {
782
59x
        dailyHorizon = 1
783
59x
    }
784

785
139x
    w := &Worker{
786
139x
        userService:          userService,
787
139x
        questionService:      questionService,
788
139x
        aiService:            aiService,
789
139x
        learningService:      learningService,
790
139x
        workerService:        workerService,
791
139x
        dailyQuestionService: dailyQuestionService,
792
139x
        wordOfTheDayService:  wordOfTheDayService,
793
139x
        storyService:         storyService,
794
139x
        emailService:         emailService,
795
139x
        hintService:          hintService,
796
139x
        translationCacheRepo: translationCacheRepo,
797
139x
        instance:             instance,
798
139x
        status:               Status{IsRunning: false, CurrentActivity: "Initialized"},
799
139x
        history:              make([]RunRecord, 0, cfg.Server.MaxHistory),
800
139x
        activityLogs:         make([]ActivityLog, 0, cfg.Server.MaxActivityLogs),
801
139x
        manualTrigger:        make(chan bool, 1),
802
139x
        cfg:                  cfg,
803
139x
        workerCfg:            Config{StartWorkerPaused: getEnvBool("WORKER_START_PAUSED", false), DailyHorizonDays: dailyHorizon},
804
139x
        logger:               logger,
805
139x
        userFailures:         make(map[int]*UserFailureInfo),
806
139x
        timeNow:              time.Now, // Default to real time
807
139x
    }
808
139x

809
139x
    // Handle startup pause if configured
810
139x
    if w.workerCfg.StartWorkerPaused {
811
        w.handleStartupPause(ctx)
812
    }
813

814
    // Store cancel function for cleanup
815
139x
    w.cancel = cancel
816
139x

817
139x
    return w
818
}
819

820
// getEnvBool is a helper function to get boolean environment variables
821
147x
func getEnvBool(key string, defaultValue bool) bool {
822
147x
    valStr := os.Getenv(key)
823
147x
    if valStr == "" {
824
141x
        return defaultValue
825
141x
    }
826
3x
    val, err := strconv.ParseBool(valStr)
827
3x
    if err != nil {
828
1x
        return defaultValue
829
1x
    }
830
2x
    return val
831
}
832

833
// Start begins the worker's background processing loop
834
1x
func (w *Worker) Start(ctx context.Context) {
835
1x
    w.status.IsRunning = true
836
1x
    w.updateDatabaseStatus(ctx)
837
1x
    w.handleStartupPause(ctx)
838
1x

839
1x
    // Start heartbeat goroutine
840
1x
    go w.heartbeatLoop(ctx)
841
1x

842
1x
    // Main worker loop
843
1x
    ticker := time.NewTicker(config.WorkerHeartbeatInterval)
844
1x
    defer ticker.Stop()
845
1x

846
1x
    initialStatus := w.getInitialWorkerStatus(ctx)
847
1x

848
1x
    w.logger.Info(ctx, "Worker started", map[string]any{
849
1x
        "instance": w.instance,
850
1x
        "status":   initialStatus,
851
1x
    })
852
1x
    w.logActivity(ctx, "INFO", fmt.Sprintf("Worker %s started (%s)", w.instance, initialStatus), nil, nil)
853
1x

854
1x
    for {
855
1x
        select {
856
1x
        case <-ctx.Done():
857
1x
            w.logger.Info(ctx, "Worker shutting down", map[string]any{
858
1x
                "instance": w.instance,
859
1x
            })
860
1x
            w.logActivity(ctx, "INFO", fmt.Sprintf("Worker %s shutting down", w.instance), nil, nil)
861
1x
            w.status.IsRunning = false
862
1x
            w.updateDatabaseStatus(ctx)
863
1x
            return
864

865
        case <-ticker.C:
866
            w.run(ctx)
867

868
        case <-w.manualTrigger:
869
            w.logger.Info(ctx, "Worker triggered manually", map[string]any{
870
                "instance": w.instance,
871
            })
872
            w.logActivity(ctx, "INFO", fmt.Sprintf("Worker %s triggered manually", w.instance), nil, nil)
873
            w.run(ctx)
874
        }
875
    }
876
}
877

878
// handleStartupPause sets global pause if configured
879
3x
func (w *Worker) handleStartupPause(ctx context.Context) {
880
3x
    if w.workerCfg.StartWorkerPaused {
881
1x
        w.logger.Info(ctx, "Worker configured to start paused - setting global pause", map[string]interface{}{
882
1x
            "instance": w.instance,
883
1x
        })
884
1x
        if err := w.workerService.SetGlobalPause(ctx, true); err != nil {
885
            w.logger.Error(ctx, "Failed to set global pause on startup", err, map[string]interface{}{
886
                "instance": w.instance,
887
            })
888
        } else {
889
1x
            w.logger.Info(ctx, "Global pause set on startup as configured", map[string]interface{}{
890
1x
                "instance": w.instance,
891
1x
            })
892
1x
        }
893
    }
894
}
895

896
// getInitialWorkerStatus determines the initial status string
897
3x
func (w *Worker) getInitialWorkerStatus(ctx context.Context) string {
898
3x
    initialStatus := "running"
899
3x
    globalPaused, err := w.workerService.IsGlobalPaused(ctx)
900
3x
    if err != nil {
901
        w.logger.Error(ctx, "Failed to check global pause status on startup", err, map[string]interface{}{
902
            "instance": w.instance,
903
        })
904
    } else if globalPaused {
905
        initialStatus = "paused (globally)"
906
    } else {
907
3x
        status, err := w.workerService.GetWorkerStatus(ctx, w.instance)
908
3x
        if err != nil {
909
            // Worker status not found is expected on first startup - this is normal
910
            w.logger.Debug(ctx, "Worker status not found on startup (expected for new worker)", map[string]interface{}{
911
                "instance": w.instance,
912
            })
913
        } else if status != nil && status.IsPaused {
914
            initialStatus = "paused (instance)"
915
1x
        }
916
    }
917
3x
    return initialStatus
918
}
919

920
2x
func (w *Worker) heartbeatLoop(ctx context.Context) {
921
2x
    ticker := time.NewTicker(config.WorkerHeartbeatInterval)
922
2x
    defer ticker.Stop()
923
2x

924
2x
    for {
925
2x
        select {
926
2x
        case <-ctx.Done():
927
2x
            return
928
        case <-ticker.C:
929
            w.updateHeartbeat(ctx)
930
        }
931
    }
932
}
933

934
// updateHeartbeat updates the heartbeat in the database
935
1x
func (w *Worker) updateHeartbeat(ctx context.Context) {
936
1x
    if err := w.workerService.UpdateHeartbeat(ctx, w.instance); err != nil {
937
        w.logger.Error(ctx, "Failed to update heartbeat for worker", err, map[string]any{
938
            "instance": w.instance,
939
        })
940
    }
941
}
942

943
// run executes a single worker cycle
944
4x
func (w *Worker) run(ctx context.Context) {
945
4x
    ctx, span := observability.TraceWorkerFunction(ctx, "run",
946
4x
        attribute.String("worker.instance", w.instance),
947
4x
    )
948
4x
    defer observability.FinishSpan(span, nil)
949
4x

950
4x
    // Ensure worker status is up to date before checking pause status
951
4x
    w.updateDatabaseStatus(ctx)
952
4x

953
4x
    paused, reason := w.checkPauseStatus(ctx)
954
4x
    if paused {
955
1x
        span.SetAttributes(attribute.String("pause_reason", reason))
956
1x
        w.updateActivity(reason)
957
1x
        return
958
1x
    }
959

960
3x
    w.status.LastRunStart = time.Now()
961
3x
    w.updateDatabaseStatus(ctx)
962
3x
    details, err := w.generateNeededQuestions(ctx)
963
3x

964
3x
    // Assign daily questions to all eligible users (independent of email reminders)
965
3x
    if err := w.checkForDailyQuestionAssignments(ctx); err != nil {
966
        w.logger.Error(ctx, "Failed to check daily question assignments", err, map[string]interface{}{
967
            "instance": w.instance,
968
        })
969
    }
970

971
    // Generate story sections for users with active stories
972
3x
    if err := w.checkForStoryGenerations(ctx); err != nil {
973
        w.logger.Error(ctx, "Failed to check story generations", err, map[string]interface{}{
974
            "instance": w.instance,
975
        })
976
    }
977

978
    // Check for daily email reminders
979
3x
    if err := w.checkForDailyReminders(ctx); err != nil {
980
        w.logger.Error(ctx, "Failed to check daily reminders", err, map[string]interface{}{
981
            "instance": w.instance,
982
        })
983
    }
984

985
    // Check for word of the day assignments
986
3x
    if err := w.checkForWordOfTheDayAssignments(ctx); err != nil {
987
        w.logger.Error(ctx, "Failed to check word of the day assignments", err, map[string]interface{}{
988
            "instance": w.instance,
989
        })
990
    }
991

992
    // Check for word of the day emails
993
3x
    if err := w.checkForWordOfTheDayEmails(ctx); err != nil {
994
        w.logger.Error(ctx, "Failed to check word of the day emails", err, map[string]interface{}{
995
            "instance": w.instance,
996
        })
997
    }
998

999
    // Cleanup expired translation cache entries (once per day)
1000
3x
    if err := w.cleanupTranslationCache(ctx); err != nil {
1001
        w.logger.Error(ctx, "Failed to cleanup translation cache", err, map[string]interface{}{
1002
            "instance": w.instance,
1003
        })
1004
    }
1005

1006
3x
    w.status.LastRunFinish = time.Now()
1007
3x
    if err != nil {
1008
        w.status.LastRunError = err.Error()
1009
        w.logger.Error(ctx, "Worker run failed", err, map[string]interface{}{
1010
            "instance": w.instance,
1011
        })
1012
    } else {
1013
3x
        w.status.LastRunError = ""
1014
3x
    }
1015

1016
3x
    w.recordRunHistory(details, err)
1017
3x
    w.updateDatabaseStatus(ctx)
1018
}
1019

1020
// checkPauseStatus checks global and instance pause
1021
6x
func (w *Worker) checkPauseStatus(ctx context.Context) (bool, string) {
1022
6x
    globalPaused, err := w.workerService.IsGlobalPaused(ctx)
1023
6x
    if err != nil {
1024
        w.logger.Error(ctx, "Failed to check global pause status", err, map[string]interface{}{
1025
            "instance": w.instance,
1026
        })
1027
        return true, "Error checking global pause status"
1028
    }
1029
6x
    if globalPaused {
1030
3x
        return true, "Globally paused"
1031
3x
    }
1032
3x
    status, err := w.workerService.GetWorkerStatus(ctx, w.instance)
1033
3x
    if err != nil {
1034
        // Worker status not found might happen during startup - assume not paused
1035
        w.logger.Debug(ctx, "Worker status not found during pause check (assuming not paused)", map[string]interface{}{
1036
            "instance": w.instance,
1037
        })
1038
        return false, ""
1039
    } else if status != nil && status.IsPaused {
1040
        return true, "Worker instance paused"
1041
    }
1042
3x
    return false, ""
1043
}
1044

1045
// recordRunHistory records the run in history and trims the slice
1046
113x
func (w *Worker) recordRunHistory(details string, err error) {
1047
113x
    record := RunRecord{
1048
113x
        StartTime: w.status.LastRunStart,
1049
113x
        EndTime:   w.status.LastRunFinish,
1050
113x
        Duration:  w.status.LastRunFinish.Sub(w.status.LastRunStart),
1051
113x
        Details:   details,
1052
113x
    }
1053
113x
    if err != nil {
1054
        record.Status = "Failure"
1055
    } else {
1056
113x
        record.Status = "Success"
1057
113x
    }
1058
113x
    w.mu.Lock()
1059
113x
    w.history = append(w.history, record)
1060
113x
    if len(w.history) > w.cfg.Server.MaxHistory {
1061
5x
        w.history = w.history[len(w.history)-w.cfg.Server.MaxHistory:]
1062
5x
    }
1063
113x
    w.mu.Unlock()
1064
}
1065

1066
// GetStatus returns the current worker status
1067
5x
func (w *Worker) GetStatus() Status {
1068
5x
    w.mu.RLock()
1069
5x
    defer w.mu.RUnlock()
1070
5x
    return w.status
1071
5x
}
1072

1073
// GetHistory returns the worker's run history
1074
8x
func (w *Worker) GetHistory() []RunRecord {
1075
8x
    w.mu.RLock()
1076
8x
    defer w.mu.RUnlock()
1077
8x
    // Return a copy to avoid race conditions
1078
8x
    history := make([]RunRecord, len(w.history))
1079
8x
    copy(history, w.history)
1080
8x
    return history
1081
8x
}
1082

1083
// checkForStoryGenerations checks for users with active stories and generates new sections
1084
3x
func (w *Worker) checkForStoryGenerations(ctx context.Context) error {
1085
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "check_story_generations",
1086
3x
        attribute.String("worker.instance", w.instance),
1087
3x
    )
1088
3x
    defer observability.FinishSpan(span, nil)
1089
3x

1090
3x
    w.updateActivity("Checking for story generations...")
1091
3x

1092
3x
    // Get all users with current active stories
1093
3x
    users, err := w.getUsersWithActiveStories(ctx)
1094
3x
    if err != nil {
1095
        return contextutils.WrapErrorf(err, "failed to get users with active stories")
1096
    }
1097

1098
3x
    w.logger.Info(ctx, "Found users with active stories",
1099
3x
        map[string]interface{}{
1100
3x
            "count":    len(users),
1101
3x
            "instance": w.instance,
1102
3x
        })
1103
3x

1104
3x
    processed := 0
1105
3x
    for _, user := range users {
1106
        if err := w.generateStorySection(ctx, user); err != nil {
1107
            // Check if this is a generation limit reached error (normal case for worker)
1108
            if errors.Is(err, contextutils.ErrGenerationLimitReached) {
1109
                w.logger.Info(ctx, "User reached daily generation limit, skipping",
1110
                    map[string]interface{}{
1111
                        "user_id":  user.ID,
1112
                        "username": user.Username,
1113
                        "instance": w.instance,
1114
                    })
1115
            } else {
1116
                w.logger.Error(ctx, "Failed to generate story section for user",
1117
                    err, map[string]interface{}{
1118
                        "user_id":  user.ID,
1119
                        "username": user.Username,
1120
                        "instance": w.instance,
1121
                    })
1122
            }
1123
            continue
1124
        }
1125
        processed++
1126
    }
1127

1128
3x
    w.updateActivity(fmt.Sprintf("Generated story sections for %d users", processed))
1129
3x
    w.logger.Info(ctx, "Story generation completed",
1130
3x
        map[string]interface{}{
1131
3x
            "processed": processed,
1132
3x
            "total":     len(users),
1133
3x
            "instance":  w.instance,
1134
3x
        })
1135
3x

1136
3x
    return nil
1137
}
1138

1139
// generateStorySection generates a new section for a user's current story
1140
func (w *Worker) generateStorySection(ctx context.Context, user models.User) error {
1141
    ctx, span := observability.TraceWorkerFunction(ctx, "generate_story_section",
1142
        attribute.String("worker.instance", w.instance),
1143
        attribute.String("user.username", user.Username),
1144
        attribute.Int("user.id", int(user.ID)),
1145
    )
1146
    defer observability.FinishSpan(span, nil)
1147

1148
    // Create a timeout context for story generation to prevent hanging requests
1149
    // Use the configured AI request timeout for consistency with other AI operations
1150
    timeoutCtx, cancel := context.WithTimeout(ctx, config.AIRequestTimeout)
1151
    defer cancel()
1152

1153
    // Get the user's current story
1154
    story, err := w.storyService.GetCurrentStory(timeoutCtx, uint(user.ID))
1155
    if err != nil {
1156
        return contextutils.WrapErrorf(err, "failed to get current story for user %d", user.ID)
1157
    }
1158
    if story == nil {
1159
        // No current story, skip
1160
        return nil
1161
    }
1162

1163
    // Get user's AI configuration
1164
    userConfig, apiKeyID := w.getUserAIConfig(timeoutCtx, &user)
1165

1166
    // Add user ID and API key ID to context for usage tracking
1167
    timeoutCtx = contextutils.WithUserID(timeoutCtx, user.ID)
1168
    if apiKeyID != nil {
1169
        timeoutCtx = contextutils.WithAPIKeyID(timeoutCtx, *apiKeyID)
1170
    }
1171

1172
    // Generate the story section using the shared service method (worker generation)
1173
    _, err = w.storyService.GenerateStorySection(timeoutCtx, story.ID, uint(user.ID), w.aiService, userConfig, models.GeneratorTypeWorker)
1174
    if err != nil {
1175
        // Check if this is a generation limit reached error (normal case for worker)
1176
        if errors.Is(err, contextutils.ErrGenerationLimitReached) {
1177
            w.logger.Info(ctx, "User reached daily generation limit, skipping",
1178
                map[string]interface{}{
1179
                    "user_id":  user.ID,
1180
                    "story_id": story.ID,
1181
                })
1182
            return nil // Skip this user, not an error
1183
        }
1184
        return contextutils.WrapErrorf(err, "failed to generate story section")
1185
    }
1186

1187
    return nil
1188
}
1189

1190
// getUsersWithActiveStories retrieves all users who have current active stories
1191
7x
func (w *Worker) getUsersWithActiveStories(ctx context.Context) ([]models.User, error) {
1192
7x
    // Get all users first
1193
7x
    allUsers, err := w.userService.GetAllUsers(ctx)
1194
7x
    if err != nil {
1195
        return nil, contextutils.WrapErrorf(err, "failed to get all users")
1196
    }
1197

1198
    // Filter to only users with current active stories and AI enabled
1199
7x
    var filteredUsers []models.User
1200
7x
    for _, user := range allUsers {
1201
5x
        // Check if user has AI enabled
1202
5x
        if !user.AIEnabled.Valid || !user.AIEnabled.Bool {
1203
1x
            continue
1204
        }
1205

1206
        // Check if user has valid AI provider and model
1207
4x
        if !user.AIProvider.Valid || !user.AIModel.Valid {
1208
            continue
1209
        }
1210

1211
        // Check if user has a current active story
1212
4x
        story, err := w.storyService.GetCurrentStory(ctx, uint(user.ID))
1213
4x
        if err != nil || story == nil {
1214
            continue
1215
        }
1216

1217
        // Check if story is active
1218
4x
        if story.Status != models.StoryStatusActive {
1219
            continue
1220
        }
1221

1222
        // Check if auto-generation is paused for this story
1223
4x
        if story.AutoGenerationPaused {
1224
            w.logger.Debug(ctx, "Skipping story with auto-generation paused",
1225
                map[string]interface{}{
1226
                    "user_id":  user.ID,
1227
                    "story_id": story.ID,
1228
                })
1229
            continue
1230
        }
1231

1232
4x
        filteredUsers = append(filteredUsers, user)
1233
    }
1234

1235
7x
    return filteredUsers, nil
1236
}
1237

1238
// GetActivityLogs returns recent activity logs
1239
7x
func (w *Worker) GetActivityLogs() []ActivityLog {
1240
7x
    w.mu.RLock()
1241
7x
    defer w.mu.RUnlock()
1242
7x

1243
7x
    // Return a copy to avoid concurrent access issues
1244
7x
    logs := make([]ActivityLog, len(w.activityLogs))
1245
7x
    copy(logs, w.activityLogs)
1246
7x
    return logs
1247
7x
}
1248

1249
// GetInstance returns the worker instance name
1250
1x
func (w *Worker) GetInstance() string {
1251
1x
    return w.instance
1252
1x
}
1253

1254
// GetEmailService returns the email service
1255
func (w *Worker) GetEmailService() mailer.Mailer {
1256
    return w.emailService
1257
}
1258

1259
// TriggerManualRun triggers a manual worker run
1260
5x
func (w *Worker) TriggerManualRun() {
1261
5x
    ctx := context.Background()
1262
5x
    select {
1263
3x
    case w.manualTrigger <- true:
1264
3x
        w.logger.Info(ctx, "Manual trigger sent to worker", map[string]interface{}{
1265
3x
            "instance": w.instance,
1266
3x
        })
1267
1x
    default:
1268
1x
        w.logger.Info(ctx, "Manual trigger already pending for worker", map[string]interface{}{
1269
1x
            "instance": w.instance,
1270
1x
        })
1271
    }
1272
}
1273

1274
// Pause pauses the worker
1275
2x
func (w *Worker) Pause(ctx context.Context) {
1276
2x
    if err := w.workerService.PauseWorker(ctx, w.instance); err != nil {
1277
1x
        w.logger.Warn(ctx, "Failed to pause worker in service", map[string]interface{}{
1278
1x
            "instance": w.instance,
1279
1x
            "error":    err.Error(),
1280
1x
        })
1281
1x
    }
1282
2x
    w.logger.Info(ctx, "Worker paused", map[string]interface{}{
1283
2x
        "instance": w.instance,
1284
2x
    })
1285
2x
    w.logActivity(ctx, "INFO", fmt.Sprintf("Worker %s paused", w.instance), nil, nil)
1286
2x
    w.status.IsPaused = true
1287
2x
    w.updateDatabaseStatus(ctx)
1288
}
1289

1290
// Resume resumes the worker
1291
2x
func (w *Worker) Resume(ctx context.Context) {
1292
2x
    if err := w.workerService.ResumeWorker(ctx, w.instance); err != nil {
1293
1x
        w.logger.Warn(ctx, "Failed to resume worker in service", map[string]interface{}{
1294
1x
            "instance": w.instance,
1295
1x
            "error":    err.Error(),
1296
1x
        })
1297
1x
        // Do not unpause if resume failed
1298
1x
        w.updateDatabaseStatus(ctx)
1299
1x
        return
1300
1x
    }
1301
1x
    w.logger.Info(ctx, "Worker resumed", map[string]interface{}{
1302
1x
        "instance": w.instance,
1303
1x
    })
1304
1x
    w.logActivity(ctx, "INFO", fmt.Sprintf("Worker %s resumed", w.instance), nil, nil)
1305
1x
    w.status.IsPaused = false
1306
1x
    w.updateDatabaseStatus(ctx)
1307
}
1308

1309
// Shutdown gracefully shuts down the worker and cleans up resources
1310
1x
func (w *Worker) Shutdown(ctx context.Context) error {
1311
1x
    w.mu.Lock()
1312
1x
    defer w.mu.Unlock()
1313
1x

1314
1x
    w.logger.Info(ctx, "Worker starting shutdown", map[string]interface{}{
1315
1x
        "instance": w.instance,
1316
1x
    })
1317
1x

1318
1x
    // Cancel the shutdown context to signal shutdown
1319
1x
    if w.cancel != nil {
1320
1x
        w.cancel()
1321
1x
    }
1322

1323
    // Wait for any active operations to complete
1324
    // This is a simple implementation - in a more complex system,
1325
    // you might want to track active operations more precisely
1326
1x
    time.Sleep(config.WorkerSleepDuration)
1327
1x

1328
1x
    // Clean up user failures map
1329
1x
    w.failureMu.Lock()
1330
1x
    w.userFailures = make(map[int]*UserFailureInfo)
1331
1x
    w.failureMu.Unlock()
1332
1x

1333
1x
    // Clear activity logs
1334
1x
    w.activityLogs = make([]ActivityLog, 0)
1335
1x

1336
1x
    w.logger.Info(ctx, "Worker shutdown completed", map[string]interface{}{
1337
1x
        "instance": w.instance,
1338
1x
    })
1339
1x
    return nil
1340
}
1341

1342
// updateDatabaseStatus updates the worker status in the database
1343
20x
func (w *Worker) updateDatabaseStatus(ctx context.Context) {
1344
20x
    dbStatus := &models.WorkerStatus{
1345
20x
        WorkerInstance:          w.instance,
1346
20x
        IsRunning:               w.status.IsRunning,
1347
20x
        IsPaused:                w.status.IsPaused,
1348
20x
        CurrentActivity:         sql.NullString{String: w.status.CurrentActivity, Valid: w.status.CurrentActivity != ""},
1349
20x
        LastHeartbeat:           sql.NullTime{Time: time.Now(), Valid: true},
1350
20x
        LastRunStart:            sql.NullTime{Time: w.status.LastRunStart, Valid: !w.status.LastRunStart.IsZero()},
1351
20x
        LastRunFinish:           sql.NullTime{Time: w.status.LastRunFinish, Valid: !w.status.LastRunFinish.IsZero()},
1352
20x
        LastRunError:            sql.NullString{String: w.status.LastRunError, Valid: w.status.LastRunError != ""},
1353
20x
        TotalQuestionsGenerated: w.getTotalQuestionsGenerated(),
1354
20x
        TotalRuns:               len(w.history),
1355
20x
    }
1356
20x

1357
20x
    if err := w.workerService.UpdateWorkerStatus(ctx, w.instance, dbStatus); err != nil {
1358
1x
        w.logger.Error(ctx, "Failed to update worker status in database", err, map[string]interface{}{
1359
1x
            "instance": w.instance,
1360
1x
        })
1361
1x
    }
1362
}
1363

1364
// getTotalQuestionsGenerated calculates total questions generated from run history
1365
20x
func (w *Worker) getTotalQuestionsGenerated() int {
1366
20x
    total := 0
1367
20x
    for _, record := range w.history {
1368
3x
        if record.Status == "Success" {
1369
3x
            // Parse details to count questions - simplified for now
1370
3x
            total++ // This would need to be enhanced to parse actual count
1371
3x
        }
1372
    }
1373
20x
    return total
1374
}
1375

1376
3x
func (w *Worker) generateNeededQuestions(ctx context.Context) (result0 string, err error) {
1377
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "generate_needed_questions",
1378
3x
        attribute.String("worker.instance", w.instance),
1379
3x
    )
1380
3x
    defer observability.FinishSpan(span, &err)
1381
3x

1382
3x
    // Check if globally paused BEFORE any work or logging
1383
3x
    globalPaused, err := w.workerService.IsGlobalPaused(ctx)
1384
3x
    if err != nil {
1385
        span.RecordError(err)
1386
        w.logger.Error(ctx, "Failed to check global pause status", err, map[string]interface{}{
1387
            "instance": w.instance,
1388
        })
1389
        return "Error checking global pause status", err
1390
    }
1391
3x
    if globalPaused {
1392
        span.SetAttributes(attribute.Bool("globally_paused", true))
1393
        w.logger.Info(ctx, "Worker skipping question generation (globally paused)", map[string]interface{}{
1394
            "instance": w.instance,
1395
        })
1396
        return "Run paused globally", nil
1397
    }
1398

1399
3x
    aiUsers, err := w.getEligibleAIUsers(ctx)
1400
3x
    if err != nil {
1401
        return "Error getting users", err
1402
    }
1403
3x
    if len(aiUsers) == 0 {
1404
3x
        w.logger.Info(ctx, "Worker: No active users with AI provider configuration found for question generation", map[string]interface{}{
1405
3x
            "instance": w.instance,
1406
3x
        })
1407
3x
        return "No active users with AI provider configuration found", nil
1408
3x
    }
1409

1410
    var actions []string
1411
    var checkedUsers []string
1412
    var actuallyProcessedUsers []string
1413
    var hadAttemptedOperations bool
1414
    var hadFailures bool
1415
    var allErrorMessages []string
1416

1417
    for _, user := range aiUsers {
1418
        checkedUsers = append(checkedUsers, user.Username)
1419
        shouldProcess, skipReason := w.shouldProcessUser(ctx, &user)
1420
        if !shouldProcess {
1421
            if skipReason != "" {
1422
                w.logger.Info(ctx, "Worker user check", map[string]interface{}{
1423
                    "instance": w.instance,
1424
                    "username": user.Username,
1425
                    "reason":   skipReason,
1426
                })
1427
            }
1428
            continue
1429
        }
1430
        actuallyProcessedUsers = append(actuallyProcessedUsers, user.Username)
1431
        userActions, attempted, failed, userErrors := w.processUserQuestionGeneration(ctx, &user)
1432
        if attempted {
1433
            hadAttemptedOperations = true
1434
        }
1435
        if failed {
1436
            hadFailures = true
1437
        }
1438
        if len(userErrors) > 0 {
1439
            allErrorMessages = append(allErrorMessages, userErrors...)
1440
        }
1441
        if userActions != "" {
1442
            actions = append(actions, userActions)
1443
        }
1444
        w.logger.Info(ctx, "Worker completed check for user", map[string]interface{}{
1445
            "instance": w.instance,
1446
            "username": user.Username,
1447
        })
1448
    }
1449

1450
    w.updateActivity("")
1451
    summary := w.summarizeRunActions(actions, checkedUsers, actuallyProcessedUsers, hadAttemptedOperations, hadFailures)
1452

1453
    // If there were failures, include error messages in the summary and return an error
1454
    if hadFailures && len(allErrorMessages) > 0 {
1455
        // Include first few error messages in summary (limit to avoid too long strings)
1456
        maxErrors := 3
1457
        if len(allErrorMessages) < maxErrors {
1458
            maxErrors = len(allErrorMessages)
1459
        }
1460
        errorSummary := strings.Join(allErrorMessages[:maxErrors], "; ")
1461
        if len(allErrorMessages) > maxErrors {
1462
            errorSummary += fmt.Sprintf(" (and %d more errors)", len(allErrorMessages)-maxErrors)
1463
        }
1464
        summaryWithErrors := fmt.Sprintf("%s\nErrors: %s", summary, errorSummary)
1465
        return summaryWithErrors, contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "Worker run completed with errors: %s", errorSummary)
1466
    }
1467

1468
    return summary, nil
1469
}
1470

1471
// getEligibleAIUsers returns users eligible for AI question generation
1472
5x
func (w *Worker) getEligibleAIUsers(ctx context.Context) (result0 []models.User, err error) {
1473
5x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_eligible_ai_users",
1474
5x
        attribute.String("worker.instance", w.instance),
1475
5x
    )
1476
5x
    defer observability.FinishSpan(span, &err)
1477
5x

1478
5x
    users, err := w.userService.GetAllUsers(ctx)
1479
5x
    if err != nil {
1480
        span.RecordError(err)
1481
        return nil, err
1482
    }
1483
5x
    var aiUsers []models.User
1484
5x
    for _, user := range users {
1485
7x
        if !user.AIEnabled.Valid || !user.AIEnabled.Bool {
1486
3x
            continue
1487
        }
1488
2x
        userPaused, err := w.workerService.IsUserPaused(ctx, user.ID)
1489
2x
        if err == nil && userPaused {
1490
1x
            continue
1491
        }
1492
1x
        hasAIProvider := user.AIProvider.Valid && user.AIProvider.String != ""
1493
1x
        hasAPIKey := false
1494
1x
        if hasAIProvider {
1495
1x
            savedKey, err := w.userService.GetUserAPIKey(ctx, user.ID, user.AIProvider.String)
1496
1x
            if err == nil && savedKey != "" {
1497
1x
                hasAPIKey = true
1498
1x
            }
1499
        }
1500
1x
        if hasAPIKey || hasAIProvider {
1501
1x
            aiUsers = append(aiUsers, user)
1502
1x
        }
1503
    }
1504
5x
    return aiUsers, nil
1505
}
1506

1507
// shouldProcessUser encapsulates exponential backoff and pause checks
1508
4x
func (w *Worker) shouldProcessUser(ctx context.Context, user *models.User) (bool, string) {
1509
4x
    if !w.shouldRetryUser(user.ID) {
1510
1x
        w.failureMu.RLock()
1511
1x
        failure := w.userFailures[user.ID]
1512
1x
        nextRetry := time.Until(failure.NextRetryTime)
1513
1x
        w.failureMu.RUnlock()
1514
1x
        return false, fmt.Sprintf("Skipping due to exponential backoff (failure #%d, retry in %v)", failure.ConsecutiveFailures, nextRetry.Round(time.Second))
1515
1x
    }
1516
3x
    globalPaused, err := w.workerService.IsGlobalPaused(ctx)
1517
3x
    if err != nil {
1518
        return false, "Error checking global pause status"
1519
    }
1520
3x
    if globalPaused {
1521
1x
        return false, "Run paused globally"
1522
1x
    }
1523
2x
    status, err := w.workerService.GetWorkerStatus(ctx, w.instance)
1524
2x
    if err == nil && status != nil && status.IsPaused {
1525
1x
        return false, fmt.Sprintf("Worker instance %s paused", w.instance)
1526
1x
    }
1527
1x
    if ctx.Err() != nil {
1528
1x
        return false, "Shutdown initiated"
1529
1x
    }
1530
    return true, ""
1531
}
1532

1533
// Helper: get the count of eligible questions for a user (excludes questions answered correctly in the last 2 days)
1534
14x
func (w *Worker) getEligibleQuestionCount(ctx context.Context, userID int, language, level string, qType models.QuestionType) (result0 int, err error) {
1535
14x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_eligible_question_count",
1536
14x
        observability.AttributeUserID(userID),
1537
14x
        attribute.String("language", language),
1538
14x
        attribute.String("level", level),
1539
14x
        attribute.String("question.type", string(qType)),
1540
14x
        attribute.String("worker.instance", w.instance),
1541
14x
    )
1542
14x
    defer observability.FinishSpan(span, &err)
1543
14x

1544
14x
    // Safe user lookup: tests may not wire userService
1545
14x
    userLookup := func(ctx context.Context, id int) (*models.User, error) {
1546
14x
        // Only use the concrete UserService implementation to avoid invoking mocks in unit tests
1547
14x
        if us, ok := w.userService.(*services.UserService); ok && us != nil {
1548
2x
            return us.GetUserByID(ctx, id)
1549
2x
        }
1550
        // No userService available or not concrete - return nil so helper falls back to UTC
1551
6x
        return nil, nil
1552
    }
1553

1554
    // Determine user-local 2-day window and pass UTC timestamps to query
1555
14x
    startUTC, endUTC, _, err := contextutils.UserLocalDayRange(ctx, userID, 2, userLookup)
1556
14x
    if err != nil {
1557
        return 0, contextutils.WrapError(err, "failed to compute user local day range")
1558
    }
1559

1560
14x
    query := `
1561
14x
        SELECT COUNT(*)
1562
14x
        FROM questions q
1563
14x
        JOIN user_questions uq ON q.id = uq.question_id
1564
14x
        WHERE uq.user_id = $1
1565
14x
          AND q.language = $2
1566
14x
          AND q.level = $3
1567
14x
          AND q.type = $4
1568
14x
          AND q.status = 'active'
1569
14x
          AND NOT EXISTS (
1570
14x
                SELECT 1 FROM user_responses ur
1571
14x
                WHERE ur.user_id = $1
1572
14x
                  AND ur.question_id = q.id
1573
14x
                  AND ur.is_correct = TRUE
1574
14x
                  AND ur.created_at >= $5 AND ur.created_at < $6
1575
14x
          )
1576
14x
    `
1577
14x

1578
14x
    // Try to get the database from the question service
1579
14x
    var db *sql.DB
1580
14x
    if qs, ok := w.questionService.(*services.QuestionService); ok {
1581
2x
        db = qs.DB()
1582
2x
    } else {
1583
6x
        // For mock services or other implementations, we can't get the DB directly
1584
6x
        // This is expected in unit tests
1585
6x
        return 0, contextutils.ErrorWithContextf("cannot get database from question service implementation")
1586
6x
    }
1587

1588
2x
    row := db.QueryRowContext(ctx, query, userID, language, level, qType, startUTC, endUTC)
1589
2x
    var count int
1590
2x
    if err := row.Scan(&count); err != nil {
1591
        return 0, err
1592
    }
1593
2x
    return count, nil
1594
}
1595

1596
1x
func (w *Worker) processUserQuestionGeneration(ctx context.Context, user *models.User) (string, bool, bool, []string) {
1597
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "processUserQuestionGeneration",
1598
1x
        observability.AttributeUserID(user.ID),
1599
1x
        attribute.String("user.username", user.Username),
1600
1x
        attribute.String("worker.instance", w.instance),
1601
1x
    )
1602
1x
    defer observability.FinishSpan(span, nil)
1603
1x

1604
1x
    userLanguage := "italian"
1605
1x
    if user.PreferredLanguage.Valid && user.PreferredLanguage.String != "" {
1606
1x
        userLanguage = user.PreferredLanguage.String
1607
1x
        span.SetAttributes(attribute.String("user.language", userLanguage))
1608
1x
    }
1609
1x
    userLevel := "A1"
1610
1x
    if user.CurrentLevel.Valid && user.CurrentLevel.String != "" {
1611
1x
        userLevel = user.CurrentLevel.String
1612
1x
        span.SetAttributes(attribute.String("user.level", userLevel))
1613
1x
    }
1614
1x
    languages := []string{userLanguage}
1615
1x
    levels := []string{userLevel}
1616
1x
    questionTypes := []models.QuestionType{
1617
1x
        models.Vocabulary,
1618
1x
        models.FillInBlank,
1619
1x
        models.QuestionAnswer,
1620
1x
        models.ReadingComprehension,
1621
1x
    }
1622
1x

1623
1x
    // Reorder types based on active generation hints (hinted types first, stable order)
1624
1x
    if w.hintService != nil {
1625
        if hints, err := w.hintService.GetActiveHintsForUser(ctx, user.ID); err == nil && len(hints) > 0 {
1626
            hinted := make([]models.QuestionType, 0, len(hints))
1627
            hintedSet := map[models.QuestionType]bool{}
1628
            for _, h := range hints {
1629
                qt := models.QuestionType(h.QuestionType)
1630
                hinted = append(hinted, qt)
1631
                hintedSet[qt] = true
1632
            }
1633
            rest := make([]models.QuestionType, 0, len(questionTypes))
1634
            for _, qt := range questionTypes {
1635
                if !hintedSet[qt] {
1636
                    rest = append(rest, qt)
1637
                }
1638
            }
1639
            questionTypes = append(hinted, rest...)
1640
        }
1641
    }
1642
1x
    var actions []string
1643
1x
    var hadAttemptedOperations bool
1644
1x
    var hadFailures bool
1645
1x
    var errorMessages []string
1646
1x
    for _, language := range languages {
1647
1x
        for _, level := range levels {
1648
1x
            for _, qType := range questionTypes {
1649
4x
                activity := fmt.Sprintf("Checking questions for user %s: %s %s %s", user.Username, language, level, qType)
1650
4x
                w.updateActivity(activity)
1651
4x
                // Use eligible question count (not just total assigned)
1652
4x
                eligibleCount, err := w.getEligibleQuestionCount(ctx, user.ID, language, level, qType)
1653
4x
                if err != nil {
1654
4x
                    span.RecordError(err)
1655
4x
                    hadFailures = true
1656
4x
                    errorMessages = append(errorMessages, fmt.Sprintf("Failed to get eligible question count for %s %s %s: %v", language, level, qType, err))
1657
4x
                    continue // Continue to next question type
1658
                }
1659
                // If hinted, be more aggressive about generating for that type
1660
                hinted := false
1661
                if w.hintService != nil {
1662
                    if hints, err := w.hintService.GetActiveHintsForUser(ctx, user.ID); err == nil {
1663
                        for _, h := range hints {
1664
                            if models.QuestionType(h.QuestionType) == qType {
1665
                                hinted = true
1666
                                break
1667
                            }
1668
                        }
1669
                    }
1670
                }
1671

1672
                refillThreshold := w.cfg.Server.QuestionRefillThreshold
1673
                if hinted {
1674
                    // Treat as if pool is empty to trigger generation, but keep batch sizing logic
1675
                    eligibleCount = 0
1676
                }
1677

1678
                if eligibleCount < refillThreshold {
1679
                    provider := "default"
1680
                    if user.AIProvider.Valid && user.AIProvider.String != "" {
1681
                        provider = user.AIProvider.String
1682
                    }
1683
                    // Base batch size from AI provider
1684
                    needed := w.aiService.GetQuestionBatchSize(provider)
1685

1686
                    // Get user's learning preferences to use their personal FreshQuestionRatio
1687
                    userPrefs, prefsErr := w.learningService.GetUserLearningPreferences(ctx, user.ID)
1688
                    userFreshRatio := 0.7 // default fallback
1689
                    if prefsErr == nil && userPrefs != nil && userPrefs.FreshQuestionRatio > 0 {
1690
                        userFreshRatio = userPrefs.FreshQuestionRatio
1691
                    } else if prefsErr != nil {
1692
                        w.logger.Warn(ctx, "Failed to get user learning preferences, using default fresh ratio", map[string]interface{}{
1693
                            "user_id": user.ID,
1694
                            "error":   prefsErr.Error(),
1695
                        })
1696
                    }
1697

1698
                    // Ensure at least enough fresh questions are available to meet the user's personal FreshQuestionRatio.
1699
                    // This ensures daily question assignment can respect the user's freshness preference.
1700
                    desiredFresh := int(math.Ceil(float64(refillThreshold) * userFreshRatio))
1701
                    freshCandidates := 0
1702
                    if qs, qerr := w.questionService.GetAdaptiveQuestionsForDaily(ctx, user.ID, language, level, 50); qerr == nil && qs != nil {
1703
                        for _, q := range qs {
1704
                            if q != nil && q.TotalResponses == 0 {
1705
                                freshCandidates++
1706
                            }
1707
                        }
1708
                    } else if qerr != nil {
1709
                        // Log but don't fail - we'll conservatively proceed with base batch size
1710
                        w.logger.Warn(ctx, "Failed to fetch adaptive questions for fresh-count check", map[string]interface{}{
1711
                            "user_id": user.ID,
1712
                            "error":   qerr.Error(),
1713
                        })
1714
                    }
1715

1716
                    if missing := desiredFresh - freshCandidates; missing > 0 {
1717
                        needed += missing
1718
                        w.logger.Info(ctx, "Adjusting generation batch to meet user's personal fresh-question requirement", map[string]interface{}{
1719
                            "user_id":          user.ID,
1720
                            "language":         language,
1721
                            "level":            level,
1722
                            "question_type":    qType,
1723
                            "user_fresh_ratio": userFreshRatio,
1724
                            "base_batch_size":  w.aiService.GetQuestionBatchSize(provider),
1725
                            "desired_fresh":    desiredFresh,
1726
                            "fresh_candidates": freshCandidates,
1727
                            "added_to_batch":   missing,
1728
                            "final_batch_size": needed,
1729
                        })
1730
                    }
1731
                    hadAttemptedOperations = true
1732
                    action, err := w.GenerateQuestionsForUser(ctx, user, language, level, qType, needed, "")
1733
                    if err != nil {
1734
                        hadFailures = true
1735
                        errorMessages = append(errorMessages, fmt.Sprintf("Failed to generate questions for %s %s %s: %v", language, level, qType, err))
1736
                        // Continue to next question type instead of breaking all loops
1737
                        continue
1738
                    }
1739
                    if action != "" {
1740
                        actions = append(actions, action)
1741
                    }
1742
                    // Clear hint on successful generation attempt for this type
1743
                    if hinted && w.hintService != nil {
1744
                        _ = w.hintService.ClearHint(ctx, user.ID, language, level, qType)
1745
                    }
1746
                }
1747
            }
1748
        }
1749
    }
1750
1x
    return strings.Join(actions, "; "), hadAttemptedOperations, hadFailures, errorMessages
1751
}
1752

1753
// summarizeRunActions builds the summary string for actions taken
1754
4x
func (w *Worker) summarizeRunActions(actions, checkedUsers, actuallyProcessedUsers []string, hadAttemptedOperations, hadFailures bool) string {
1755
4x
    userList := "No users with AI configuration found"
1756
4x
    if len(checkedUsers) > 0 {
1757
4x
        userList = fmt.Sprintf("Checked users: %s", strings.Join(checkedUsers, ", "))
1758
4x
    }
1759
4x
    if len(actions) == 0 {
1760
3x
        if len(actuallyProcessedUsers) == 0 {
1761
1x
            return fmt.Sprintf("No actions taken. All users in exponential backoff. %s", userList)
1762
1x
        }
1763
2x
        if hadAttemptedOperations && hadFailures && len(actions) == 0 {
1764
1x
            return fmt.Sprintf("No actions taken due to errors. %s", userList)
1765
1x
        }
1766
1x
        return fmt.Sprintf("No actions taken. All question types have sufficient questions. %s", userList)
1767
    }
1768
1x
    userList = fmt.Sprintf("Processed users: %s", strings.Join(actuallyProcessedUsers, ", "))
1769
1x

1770
1x
    // Format actions with line breaks for better readability in UI
1771
1x
    if len(actions) == 1 {
1772
1x
        return fmt.Sprintf("%s\n%s", actions[0], userList)
1773
1x
    }
1774

1775
    formattedActions := strings.Join(actions, "\n")
1776
    return fmt.Sprintf("%s\n%s", formattedActions, userList)
1777
}
1778

1779
// GenerateQuestionsForUser generates questions for a specific user with the given parameters
1780
1x
func (w *Worker) GenerateQuestionsForUser(ctx context.Context, user *models.User, language, level string, qType models.QuestionType, count int, topic string) (result0 string, err error) {
1781
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "generate_questions_for_user",
1782
1x
        observability.AttributeUserID(user.ID),
1783
1x
        attribute.String("user.username", user.Username),
1784
1x
        attribute.String("language", language),
1785
1x
        attribute.String("level", level),
1786
1x
        attribute.String("question.type", string(qType)),
1787
1x
        attribute.Int("question.count", count),
1788
1x
        attribute.String("topic", topic),
1789
1x
        attribute.String("worker.instance", w.instance),
1790
1x
    )
1791
1x
    defer observability.FinishSpan(span, &err)
1792
1x

1793
1x
    if count <= 0 {
1794
        return "No questions needed", nil
1795
    }
1796

1797
    // Gather priority data for variety selection
1798
1x
    priorityData := w.getPriorityGenerationData(ctx, user.ID, language, level, qType)
1799
1x
    var userWeakAreas []string
1800
1x
    if priorityData != nil && priorityData.FocusOnWeakAreas {
1801
        userWeakAreas = priorityData.UserWeakAreas
1802
    }
1803
1x
    var highPriorityTopics []string
1804
1x
    if priorityData != nil {
1805
1x
        highPriorityTopics = priorityData.HighPriorityTopics
1806
1x
    }
1807
1x
    var gapAnalysis map[string]int
1808
1x
    if priorityData != nil {
1809
1x
        gapAnalysis = priorityData.GapAnalysis
1810
1x
    }
1811

1812
1x
    variety := w.aiService.VarietyService().SelectVarietyElements(ctx, level, highPriorityTopics, userWeakAreas, gapAnalysis)
1813
1x

1814
1x
    // Log priority generation decisions
1815
1x
    generationReasoning := w.getGenerationReasoning(priorityData, variety)
1816
1x

1817
1x
    var freshQuestionRatio float64
1818
1x
    if priorityData != nil {
1819
1x
        freshQuestionRatio = priorityData.FreshQuestionRatio
1820
1x
    }
1821

1822
1x
    priorityLog := PriorityGenerationLog{
1823
1x
        UserID:              user.ID,
1824
1x
        Username:            user.Username,
1825
1x
        Language:            language,
1826
1x
        Level:               level,
1827
1x
        QuestionType:        string(qType),
1828
1x
        FocusOnWeakAreas:    priorityData != nil && priorityData.FocusOnWeakAreas,
1829
1x
        UserWeakAreas:       userWeakAreas,
1830
1x
        HighPriorityTopics:  highPriorityTopics,
1831
1x
        GapAnalysis:         gapAnalysis,
1832
1x
        FreshQuestionRatio:  freshQuestionRatio,
1833
1x
        SelectedVariety:     variety,
1834
1x
        GenerationReasoning: generationReasoning,
1835
1x
        Timestamp:           time.Now(),
1836
1x
    }
1837
1x
    w.logPriorityGeneration(ctx, priorityLog)
1838
1x

1839
1x
    aiReq, recentQuestions, err := w.buildAIQuestionGenRequest(ctx, user, language, level, qType, count, topic)
1840
1x
    if err != nil {
1841
        w.logger.Warn(ctx, "Worker failed to get recent questions", map[string]interface{}{
1842
            "instance": w.instance,
1843
            "error":    err.Error(),
1844
        })
1845
        return "", contextutils.WrapError(err, "failed to build AI request")
1846
    }
1847
1x
    aiReq.RecentQuestionHistory = recentQuestions
1848
1x

1849
1x
    userConfig, apiKeyID := w.getUserAIConfig(ctx, user)
1850
1x

1851
1x
    batchLogMsg := formatBatchLogMessage(user.Username, count, string(qType), language, level, variety, userConfig.Provider, userConfig.Model)
1852
1x
    w.logger.Info(ctx, batchLogMsg, map[string]interface{}{
1853
1x
        "instance": w.instance,
1854
1x
    })
1855
1x
    w.updateActivity(batchLogMsg)
1856
1x
    w.logActivity(ctx, "INFO", batchLogMsg, &user.ID, &user.Username)
1857
1x

1858
1x
    progressMsg, questions, errAI := w.handleAIQuestionStream(ctx, userConfig, apiKeyID, aiReq, variety, count, language, level, qType, topic, user)
1859
1x

1860
1x
    if errAI != nil {
1861
        w.recordUserFailure(ctx, user.ID, user.Username)
1862
        return progressMsg, errAI
1863
    }
1864
1x
    if len(questions) == 0 {
1865
        w.recordUserFailure(ctx, user.ID, user.Username)
1866
        return progressMsg, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "AI service returned 0 questions for %s %s %s", language, level, qType)
1867
    }
1868

1869
1x
    savedCount := w.saveGeneratedQuestions(ctx, user, questions, language, level, qType, topic, variety)
1870
1x

1871
1x
    if savedCount > 0 {
1872
1x
        w.recordUserSuccess(ctx, user.ID, user.Username)
1873
1x
    }
1874
1x
    if savedCount != len(questions) {
1875
        w.recordUserFailure(ctx, user.ID, user.Username)
1876
        return fmt.Sprintf("Generated %d/%d %s questions for %s %s", savedCount, len(questions), qType, language, level),
1877
            contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "only saved %d out of %d generated questions", savedCount, len(questions))
1878
    }
1879
1x
    return fmt.Sprintf("Generated %d %s questions for %s %s", savedCount, qType, language, level), nil
1880
}
1881

1882
// buildAIQuestionGenRequest prepares the AI request and gets recent questions
1883
5x
func (w *Worker) buildAIQuestionGenRequest(ctx context.Context, user *models.User, language, level string, qType models.QuestionType, count int, _ string) (result0 *models.AIQuestionGenRequest, result1 []string, err error) {
1884
5x
    ctx, span := observability.TraceWorkerFunction(ctx, "build_ai_question_gen_request",
1885
5x
        observability.AttributeUserID(user.ID),
1886
5x
        attribute.String("user.username", user.Username),
1887
5x
        attribute.String("language", language),
1888
5x
        attribute.String("level", level),
1889
5x
        attribute.String("question.type", string(qType)),
1890
5x
        attribute.Int("question.count", count),
1891
5x
        attribute.String("worker.instance", w.instance),
1892
5x
    )
1893
5x
    defer observability.FinishSpan(span, &err)
1894
5x

1895
5x
    recentQuestions, err := w.questionService.GetRecentQuestionContentsForUser(ctx, user.ID, 10)
1896
5x
    if err != nil {
1897
        span.RecordError(err)
1898
        return nil, nil, err
1899
    }
1900
5x
    aiReq := &models.AIQuestionGenRequest{
1901
5x
        Language:     language,
1902
5x
        Level:        level,
1903
5x
        QuestionType: qType,
1904
5x
        Count:        count,
1905
5x
    }
1906
5x

1907
5x
    aiReq.RecentQuestionHistory = recentQuestions
1908
5x

1909
5x
    return aiReq, recentQuestions, nil
1910
}
1911

1912
// getUserAIConfig builds the UserAIConfig struct with API key and returns the API key ID
1913
9x
func (w *Worker) getUserAIConfig(ctx context.Context, user *models.User) (*models.UserAIConfig, *int) {
1914
9x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_user_ai_config",
1915
9x
        observability.AttributeUserID(user.ID),
1916
9x
        attribute.String("user.username", user.Username),
1917
9x
        attribute.String("worker.instance", w.instance),
1918
9x
    )
1919
9x
    defer observability.FinishSpan(span, nil)
1920
9x

1921
9x
    provider := ""
1922
9x
    if user.AIProvider.Valid {
1923
7x
        provider = user.AIProvider.String
1924
7x
        span.SetAttributes(attribute.String("ai.provider", provider))
1925
7x
    }
1926
9x
    model := ""
1927
9x
    if user.AIModel.Valid {
1928
7x
        model = user.AIModel.String
1929
7x
        span.SetAttributes(attribute.String("ai.model", model))
1930
7x
    }
1931
9x
    apiKey := ""
1932
9x
    var apiKeyID *int
1933
9x
    if provider != "" {
1934
7x
        savedKey, keyID, err := w.userService.GetUserAPIKeyWithID(ctx, user.ID, provider)
1935
7x
        if err == nil && savedKey != "" {
1936
3x
            apiKey = savedKey
1937
3x
            apiKeyID = keyID
1938
3x
        }
1939
    }
1940
9x
    return &models.UserAIConfig{
1941
9x
        Provider: provider,
1942
9x
        Model:    model,
1943
9x
        APIKey:   apiKey,
1944
9x
        Username: user.Username,
1945
9x
    }, apiKeyID
1946
}
1947

1948
// handleAIQuestionStream handles the AI streaming and collects questions
1949
5x
func (w *Worker) handleAIQuestionStream(ctx context.Context, userConfig *models.UserAIConfig, apiKeyID *int, req *models.AIQuestionGenRequest, variety *services.VarietyElements, count int, language, level string, qType models.QuestionType, topic string, user *models.User) (result0 string, result1 []*models.Question, err error) {
1950
5x
    ctx, span := observability.TraceWorkerFunction(ctx, "handle_ai_question_stream",
1951
5x
        attribute.String("ai.provider", userConfig.Provider),
1952
5x
        attribute.String("ai.model", userConfig.Model),
1953
5x
        attribute.String("language", language),
1954
5x
        attribute.String("level", level),
1955
5x
        attribute.String("question.type", string(qType)),
1956
5x
        attribute.Int("question.count", count),
1957
5x
        attribute.String("topic", topic),
1958
5x
        attribute.String("user.username", user.Username),
1959
5x
        attribute.String("worker.instance", w.instance),
1960
5x
    )
1961
5x
    defer observability.FinishSpan(span, &err)
1962
5x

1963
5x
    // Add user ID and API key ID to context for usage tracking
1964
5x
    ctx = contextutils.WithUserID(ctx, user.ID)
1965
5x
    if apiKeyID != nil {
1966
1x
        ctx = contextutils.WithAPIKeyID(ctx, *apiKeyID)
1967
1x
    }
1968

1969
5x
    progressChan := make(chan *models.Question)
1970
5x
    var questions []*models.Question
1971
5x
    var wg sync.WaitGroup
1972
5x
    var errAI error
1973
5x
    progressMsg := ""
1974
5x
    wg.Add(1)
1975
5x
    go func() {
1976
5x
        defer func() {
1977
5x
            if r := recover(); r != nil {
1978
                w.logger.Error(ctx, "Panic in AI question stream goroutine", nil, map[string]interface{}{
1979
                    "instance": w.instance,
1980
                    "panic":    fmt.Sprintf("%v", r),
1981
                })
1982
            }
1983
5x
            wg.Done()
1984
        }()
1985
5x
        errAI = w.aiService.GenerateQuestionsStream(ctx, userConfig, req, progressChan, variety)
1986
    }()
1987
5x
    generatedCount := 0
1988
5x
    for question := range progressChan {
1989
1x
        generatedCount++
1990
1x
        progressMsg = fmt.Sprintf("Generated %d/%d %s questions for %s %s", generatedCount, count, qType, language, level)
1991
1x
        if topic != "" {
1992
1x
            progressMsg = fmt.Sprintf("Generated %d/%d %s questions for %s %s (topic: %s)", generatedCount, count, qType, language, level, topic)
1993
1x
        }
1994
1x
        w.logger.Info(ctx, progressMsg, map[string]interface{}{
1995
1x
            "instance": w.instance,
1996
1x
        })
1997
1x
        w.updateActivity(progressMsg)
1998
1x
        w.logActivity(ctx, "INFO", progressMsg, &user.ID, &user.Username)
1999
1x
        questions = append(questions, question)
2000
    }
2001
5x
    wg.Wait()
2002
5x
    return progressMsg, questions, errAI
2003
}
2004

2005
// saveGeneratedQuestions saves questions to the DB and returns the count
2006
7x
func (w *Worker) saveGeneratedQuestions(ctx context.Context, user *models.User, questions []*models.Question, language, level string, qType models.QuestionType, topic string, variety *services.VarietyElements) int {
2007
7x
    ctx, span := observability.TraceWorkerFunction(ctx, "save_generated_questions",
2008
7x
        observability.AttributeUserID(user.ID),
2009
7x
        attribute.String("user.username", user.Username),
2010
7x
        attribute.String("language", language),
2011
7x
        attribute.String("level", level),
2012
7x
        attribute.String("question.type", string(qType)),
2013
7x
        attribute.Int("question.count", len(questions)),
2014
7x
        attribute.String("topic", topic),
2015
7x
        attribute.String("worker.instance", w.instance),
2016
7x
    )
2017
7x
    defer observability.FinishSpan(span, nil)
2018
7x

2019
7x
    savingMsg := fmt.Sprintf("Saving %d new %s questions for %s %s", len(questions), qType, language, level)
2020
7x
    if topic != "" {
2021
3x
        savingMsg = fmt.Sprintf("Saving %d new %s questions for %s %s (topic: %s)", len(questions), qType, language, level, topic)
2022
3x
    }
2023
7x
    w.logger.Info(ctx, savingMsg, map[string]interface{}{
2024
7x
        "instance": w.instance,
2025
7x
    })
2026
7x
    w.updateActivity(savingMsg)
2027
7x
    w.logActivity(ctx, "INFO", savingMsg, &user.ID, &user.Username)
2028
7x
    savedCount := 0
2029
7x
    for _, q := range questions {
2030
9x
        // Populate variety fields from the variety elements used during generation
2031
9x
        if variety != nil {
2032
7x
            q.TopicCategory = variety.TopicCategory
2033
7x
            q.GrammarFocus = variety.GrammarFocus
2034
7x
            q.VocabularyDomain = variety.VocabularyDomain
2035
7x
            q.Scenario = variety.Scenario
2036
7x
            q.StyleModifier = variety.StyleModifier
2037
7x
            q.DifficultyModifier = variety.DifficultyModifier
2038
7x
            q.TimeContext = variety.TimeContext
2039
7x
        }
2040
9x
        if err := w.questionService.SaveQuestion(ctx, q); err != nil {
2041
            w.logger.Error(ctx, "Failed to save generated question", err, map[string]interface{}{
2042
                "instance":      w.instance,
2043
                "user_id":       user.ID,
2044
                "language":      language,
2045
                "level":         level,
2046
                "question_type": qType,
2047
            })
2048
        } else {
2049
9x
            // Assign the question to the user after saving
2050
9x
            if err := w.questionService.AssignQuestionToUser(ctx, q.ID, user.ID); err != nil {
2051
                w.logger.Error(ctx, "Failed to assign question to user", err, map[string]interface{}{
2052
                    "instance":    w.instance,
2053
                    "question_id": q.ID,
2054
                    "user_id":     user.ID,
2055
                })
2056
            } else {
2057
9x
                savedCount++
2058
9x
            }
2059
        }
2060
    }
2061
7x
    if savedCount > 0 {
2062
7x
        successMsg := fmt.Sprintf("Successfully saved %d new '%s' questions for %s %s", savedCount, qType, language, level)
2063
7x
        if topic != "" {
2064
3x
            successMsg = fmt.Sprintf("Successfully saved %d new '%s' questions for %s %s (topic: %s)", savedCount, qType, language, level, topic)
2065
3x
        }
2066
7x
        w.logActivity(ctx, "INFO", successMsg, &user.ID, &user.Username)
2067
    }
2068
7x
    return savedCount
2069
}
2070

2071
26x
func (w *Worker) updateActivity(activity string) {
2072
26x
    w.mu.Lock()
2073
26x
    defer w.mu.Unlock()
2074
26x
    w.status.CurrentActivity = activity
2075
26x
}
2076

2077
// logActivity adds an activity log entry
2078
247x
func (w *Worker) logActivity(_ context.Context, _, message string, userID *int, username *string) {
2079
247x
    w.mu.Lock()
2080
247x
    defer w.mu.Unlock()
2081
247x

2082
247x
    logEntry := ActivityLog{
2083
247x
        Timestamp: time.Now(),
2084
247x
        Level:     "INFO",
2085
247x
        Message:   message,
2086
247x
        UserID:    userID,
2087
247x
        Username:  username,
2088
247x
    }
2089
247x

2090
247x
    // Add to activity logs (circular buffer)
2091
247x
    w.activityLogs = append(w.activityLogs, logEntry)
2092
247x

2093
247x
    // Keep only the last maxActivityLogs entries
2094
247x
    if len(w.activityLogs) > w.cfg.Server.MaxActivityLogs {
2095
10x
        w.activityLogs = w.activityLogs[len(w.activityLogs)-w.cfg.Server.MaxActivityLogs:]
2096
10x
    }
2097
}
2098

2099
// shouldRetryUser checks if enough time has passed since the last failure for exponential backoff
2100
7x
func (w *Worker) shouldRetryUser(userID int) bool {
2101
7x
    w.failureMu.RLock()
2102
7x
    defer w.failureMu.RUnlock()
2103
7x

2104
7x
    failure, exists := w.userFailures[userID]
2105
7x
    if !exists {
2106
4x
        return true // No previous failures, go ahead
2107
4x
    }
2108

2109
3x
    return time.Now().After(failure.NextRetryTime)
2110
}
2111

2112
// recordUserFailure records a failure and calculates the next retry time with exponential backoff
2113
11x
func (w *Worker) recordUserFailure(ctx context.Context, userID int, username string) {
2114
11x
    ctx, span := observability.TraceWorkerFunction(ctx, "record_user_failure",
2115
11x
        observability.AttributeUserID(userID),
2116
11x
        attribute.String("user.username", username),
2117
11x
        attribute.String("worker.instance", w.instance),
2118
11x
    )
2119
11x
    defer observability.FinishSpan(span, nil)
2120
11x

2121
11x
    w.failureMu.Lock()
2122
11x
    defer w.failureMu.Unlock()
2123
11x

2124
11x
    failure, exists := w.userFailures[userID]
2125
11x
    if !exists {
2126
7x
        failure = &UserFailureInfo{}
2127
7x
        w.userFailures[userID] = failure
2128
7x
    }
2129

2130
11x
    failure.ConsecutiveFailures++
2131
11x
    failure.LastFailureTime = time.Now()
2132
11x

2133
11x
    // Exponential backoff: 2^failures seconds, max 1 hour
2134
11x
    backoffSeconds := int(math.Pow(2, float64(failure.ConsecutiveFailures)))
2135
11x
    if backoffSeconds > 3600 {
2136
        backoffSeconds = 3600
2137
    }
2138
11x
    failure.NextRetryTime = time.Now().Add(time.Duration(backoffSeconds) * time.Second)
2139
11x

2140
11x
    span.SetAttributes(
2141
11x
        attribute.Int("failure.count", failure.ConsecutiveFailures),
2142
11x
        attribute.Int("backoff.seconds", backoffSeconds),
2143
11x
    )
2144
11x

2145
11x
    w.logger.Info(ctx, "Worker recorded user failure", map[string]interface{}{
2146
11x
        "instance":           w.instance,
2147
11x
        "username":           username,
2148
11x
        "failure_count":      failure.ConsecutiveFailures,
2149
11x
        "next_retry_seconds": backoffSeconds,
2150
11x
    })
2151
}
2152

2153
// recordUserSuccess clears the failure count for a user
2154
5x
func (w *Worker) recordUserSuccess(ctx context.Context, userID int, username string) {
2155
5x
    ctx, span := observability.TraceWorkerFunction(ctx, "record_user_success",
2156
5x
        observability.AttributeUserID(userID),
2157
5x
        attribute.String("user.username", username),
2158
5x
        attribute.String("worker.instance", w.instance),
2159
5x
    )
2160
5x
    defer observability.FinishSpan(span, nil)
2161
5x

2162
5x
    w.failureMu.Lock()
2163
5x
    defer w.failureMu.Unlock()
2164
5x

2165
5x
    failure, exists := w.userFailures[userID]
2166
5x
    if exists && failure.ConsecutiveFailures > 0 {
2167
1x
        span.SetAttributes(attribute.Int("previous_failures", failure.ConsecutiveFailures))
2168
1x
        w.logger.Info(ctx, "Worker user success after failures, resetting backoff", map[string]interface{}{
2169
1x
            "instance":          w.instance,
2170
1x
            "username":          username,
2171
1x
            "previous_failures": failure.ConsecutiveFailures,
2172
1x
        })
2173
1x
        delete(w.userFailures, userID)
2174
1x
    }
2175
}
2176

2177
// formatBatchLogMessage creates a formatted log message for batch question generation
2178
7x
func formatBatchLogMessage(username string, count int, qType, language, level string, variety *services.VarietyElements, provider, model string) string {
2179
7x
    var summaryFields []string
2180
7x
    if variety != nil {
2181
5x
        if variety.GrammarFocus != "" {
2182
2x
            summaryFields = append(summaryFields, "grammar: "+variety.GrammarFocus)
2183
2x
        }
2184
5x
        if variety.TopicCategory != "" {
2185
2x
            summaryFields = append(summaryFields, "topic: "+variety.TopicCategory)
2186
2x
        }
2187
5x
        if variety.Scenario != "" {
2188
1x
            summaryFields = append(summaryFields, "scenario: "+variety.Scenario)
2189
1x
        }
2190
5x
        if variety.StyleModifier != "" {
2191
3x
            summaryFields = append(summaryFields, "style: "+variety.StyleModifier)
2192
3x
        }
2193
5x
        if variety.DifficultyModifier != "" {
2194
1x
            summaryFields = append(summaryFields, "difficulty: "+variety.DifficultyModifier)
2195
1x
        }
2196
5x
        if variety.VocabularyDomain != "" {
2197
1x
            summaryFields = append(summaryFields, "vocab: "+variety.VocabularyDomain)
2198
1x
        }
2199
5x
        if variety.TimeContext != "" {
2200
3x
            summaryFields = append(summaryFields, "time: "+variety.TimeContext)
2201
3x
        }
2202
    }
2203
7x
    providerModel := "provider: " + provider + ", model: " + model
2204
7x
    if len(summaryFields) > 0 {
2205
5x
        summaryFields = append(summaryFields, providerModel)
2206
5x
    } else {
2207
1x
        summaryFields = []string{providerModel}
2208
1x
    }
2209
7x
    return fmt.Sprintf("Worker [user=%s]: Batch %d %s questions (lang: %s, level: %s) | %s", username, count, qType, language, level, strings.Join(summaryFields, " | "))
2210
}
2211

2212
// PriorityGenerationData contains priority information to guide AI question generation
2213
type PriorityGenerationData struct {
2214
    UserWeakAreas        []string                        `json:"user_weak_areas,omitempty"`
2215
    HighPriorityTopics   []string                        `json:"high_priority_topics,omitempty"`
2216
    GapAnalysis          map[string]int                  `json:"gap_analysis,omitempty"`
2217
    UserPreferences      *models.UserLearningPreferences `json:"user_preferences,omitempty"`
2218
    PriorityDistribution map[string]int                  `json:"priority_distribution,omitempty"`
2219
    FocusOnWeakAreas     bool                            `json:"focus_on_weak_areas"`
2220
    FreshQuestionRatio   float64                         `json:"fresh_question_ratio"`
2221
}
2222

2223
// PriorityGenerationLog contains structured data about priority-aware generation decisions
2224
type PriorityGenerationLog struct {
2225
    UserID              int                       `json:"user_id"`
2226
    Username            string                    `json:"username"`
2227
    Language            string                    `json:"language"`
2228
    Level               string                    `json:"level"`
2229
    QuestionType        string                    `json:"question_type"`
2230
    FocusOnWeakAreas    bool                      `json:"focus_on_weak_areas"`
2231
    UserWeakAreas       []string                  `json:"user_weak_areas,omitempty"`
2232
    HighPriorityTopics  []string                  `json:"high_priority_topics,omitempty"`
2233
    GapAnalysis         map[string]int            `json:"gap_analysis,omitempty"`
2234
    FreshQuestionRatio  float64                   `json:"fresh_question_ratio"`
2235
    SelectedVariety     *services.VarietyElements `json:"selected_variety"`
2236
    GenerationReasoning string                    `json:"generation_reasoning"`
2237
    Timestamp           time.Time                 `json:"timestamp"`
2238
}
2239

2240
// logPriorityGeneration logs priority generation data as JSON
2241
1x
func (w *Worker) logPriorityGeneration(ctx context.Context, priorityLog PriorityGenerationLog) {
2242
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "log_priority_generation",
2243
1x
        observability.AttributeUserID(priorityLog.UserID),
2244
1x
        attribute.String("user.username", priorityLog.Username),
2245
1x
        attribute.String("language", priorityLog.Language),
2246
1x
        attribute.String("level", priorityLog.Level),
2247
1x
        attribute.String("question.type", priorityLog.QuestionType),
2248
1x
        attribute.String("worker.instance", w.instance),
2249
1x
    )
2250
1x
    defer observability.FinishSpan(span, nil)
2251
1x

2252
1x
    logJSON, err := json.Marshal(priorityLog)
2253
1x
    if err != nil {
2254
        span.RecordError(err)
2255
        w.logger.Error(ctx, "Failed to marshal priority generation log", err, map[string]interface{}{
2256
            "instance": w.instance,
2257
        })
2258
        return
2259
    }
2260
1x
    w.logger.Info(ctx, "Worker priority generation", map[string]interface{}{
2261
1x
        "instance": w.instance,
2262
1x
        "data":     string(logJSON),
2263
1x
    })
2264
}
2265

2266
// getGenerationReasoning provides a human-readable explanation of the generation strategy
2267
17x
func (w *Worker) getGenerationReasoning(priorityData *PriorityGenerationData, variety *services.VarietyElements) string {
2268
17x
    if priorityData == nil {
2269
1x
        return "standard generation"
2270
1x
    }
2271
15x
    var reasons []string
2272
15x

2273
15x
    if priorityData.FocusOnWeakAreas && len(priorityData.UserWeakAreas) > 0 {
2274
2x
        reasons = append(reasons, fmt.Sprintf("focusing on weak areas: %s", strings.Join(priorityData.UserWeakAreas, ", ")))
2275
2x
    }
2276

2277
15x
    if len(priorityData.HighPriorityTopics) > 0 {
2278
2x
        reasons = append(reasons, fmt.Sprintf("high priority topics: %s", strings.Join(priorityData.HighPriorityTopics, ", ")))
2279
2x
    }
2280

2281
15x
    if len(priorityData.GapAnalysis) > 0 {
2282
2x
        var gaps []string
2283
2x
        for topic, count := range priorityData.GapAnalysis {
2284
2x
            gaps = append(gaps, fmt.Sprintf("%s(%d)", topic, count))
2285
2x
        }
2286
2x
        reasons = append(reasons, fmt.Sprintf("gap analysis: %s", strings.Join(gaps, ", ")))
2287
    }
2288

2289
15x
    if priorityData.FreshQuestionRatio > 0 {
2290
9x
        reasons = append(reasons, fmt.Sprintf("fresh ratio: %.1f%%", priorityData.FreshQuestionRatio*100))
2291
9x
    }
2292

2293
15x
    if variety != nil {
2294
1x
        var varietyElements []string
2295
1x
        if variety.TopicCategory != "" {
2296
            varietyElements = append(varietyElements, fmt.Sprintf("topic:%s", variety.TopicCategory))
2297
        }
2298
1x
        if variety.GrammarFocus != "" {
2299
            varietyElements = append(varietyElements, fmt.Sprintf("grammar:%s", variety.GrammarFocus))
2300
        }
2301
1x
        if variety.VocabularyDomain != "" {
2302
            varietyElements = append(varietyElements, fmt.Sprintf("vocab:%s", variety.VocabularyDomain))
2303
        }
2304
1x
        if variety.Scenario != "" {
2305
            varietyElements = append(varietyElements, fmt.Sprintf("scenario:%s", variety.Scenario))
2306
        }
2307
1x
        if len(varietyElements) > 0 {
2308
            reasons = append(reasons, fmt.Sprintf("variety: %s", strings.Join(varietyElements, ", ")))
2309
        }
2310
    }
2311

2312
15x
    if len(reasons) == 0 {
2313
        return "standard generation"
2314
    }
2315

2316
15x
    return strings.Join(reasons, "; ")
2317
}
2318

2319
// getPriorityGenerationData gathers priority data for AI question generation
2320
1x
func (w *Worker) getPriorityGenerationData(ctx context.Context, userID int, language, level string, questionType models.QuestionType) *PriorityGenerationData {
2321
1x
    // Get user preferences
2322
1x
    prefs, err := w.learningService.GetUserLearningPreferences(ctx, userID)
2323
1x
    if err != nil {
2324
        w.logger.Warn(ctx, "Worker failed to get user preferences", map[string]interface{}{
2325
            "instance": w.instance,
2326
            "user_id":  userID,
2327
            "error":    err.Error(),
2328
        })
2329
        prefs = w.getDefaultLearningPreferences()
2330
    }
2331

2332
    // Get weak areas
2333
1x
    weakAreas, err := w.learningService.GetUserWeakAreas(ctx, userID, 5)
2334
1x
    if err != nil {
2335
        w.logger.Warn(ctx, "Worker failed to get weak areas", map[string]interface{}{
2336
            "instance": w.instance,
2337
            "user_id":  userID,
2338
            "error":    err.Error(),
2339
        })
2340
        weakAreas = []map[string]interface{}{}
2341
    }
2342

2343
    // Convert weak areas to topic strings
2344
1x
    var weakAreaTopics []string
2345
1x
    for _, area := range weakAreas {
2346
        if topic, ok := area["topic"].(string); ok && topic != "" {
2347
            weakAreaTopics = append(weakAreaTopics, topic)
2348
        }
2349
    }
2350

2351
    // Get high priority topics
2352
1x
    highPriorityTopics, err := w.getHighPriorityTopics(ctx, userID, language, level, questionType)
2353
1x
    if err != nil {
2354
        w.logger.Warn(ctx, "Worker failed to get high priority topics", map[string]interface{}{
2355
            "instance": w.instance,
2356
            "user_id":  userID,
2357
            "error":    err.Error(),
2358
        })
2359
        highPriorityTopics = []string{}
2360
    }
2361

2362
    // Get gap analysis
2363
1x
    gapAnalysis, err := w.getGapAnalysis(ctx, userID, language, level, questionType)
2364
1x
    if err != nil {
2365
        w.logger.Warn(ctx, "Worker failed to get gap analysis", map[string]interface{}{
2366
            "instance": w.instance,
2367
            "user_id":  userID,
2368
            "error":    err.Error(),
2369
        })
2370
        gapAnalysis = map[string]int{}
2371
    }
2372

2373
    // Get priority distribution
2374
1x
    priorityDistribution, err := w.getPriorityDistribution(ctx, userID, language, level, questionType)
2375
1x
    if err != nil {
2376
        w.logger.Warn(ctx, "Worker failed to get priority distribution", map[string]interface{}{
2377
            "instance": w.instance,
2378
            "user_id":  userID,
2379
            "error":    err.Error(),
2380
        })
2381
        priorityDistribution = map[string]int{}
2382
    }
2383

2384
    // Determine if we should focus on weak areas
2385
1x
    focusOnWeakAreas := len(weakAreaTopics) > 0 && prefs != nil && prefs.FocusOnWeakAreas
2386
1x

2387
1x
    return &PriorityGenerationData{
2388
1x
        UserWeakAreas:        weakAreaTopics,
2389
1x
        HighPriorityTopics:   highPriorityTopics,
2390
1x
        GapAnalysis:          gapAnalysis,
2391
1x
        UserPreferences:      prefs,
2392
1x
        PriorityDistribution: priorityDistribution,
2393
1x
        FocusOnWeakAreas:     focusOnWeakAreas,
2394
1x
        FreshQuestionRatio:   prefs.FreshQuestionRatio,
2395
1x
    }
2396
}
2397

2398
// getDefaultLearningPreferences returns default learning preferences
2399
func (w *Worker) getDefaultLearningPreferences() *models.UserLearningPreferences {
2400
    return &models.UserLearningPreferences{
2401
        FocusOnWeakAreas:   false,
2402
        FreshQuestionRatio: 0.3,
2403
        WeakAreaBoost:      1.5,
2404
    }
2405
}
2406

2407
// getHighPriorityTopics returns topics that have high average priority scores
2408
20x
func (w *Worker) getHighPriorityTopics(ctx context.Context, userID int, language, level string, questionType models.QuestionType) (result0 []string, err error) {
2409
20x
    return w.workerService.GetHighPriorityTopics(ctx, userID, language, level, string(questionType))
2410
20x
}
2411

2412
// getGapAnalysis identifies areas with insufficient questions available
2413
23x
func (w *Worker) getGapAnalysis(ctx context.Context, userID int, language, level string, questionType models.QuestionType) (result0 map[string]int, err error) {
2414
23x
    return w.workerService.GetGapAnalysis(ctx, userID, language, level, string(questionType))
2415
23x
}
2416

2417
// getPriorityDistribution returns the distribution of priority scores
2418
20x
func (w *Worker) getPriorityDistribution(ctx context.Context, userID int, language, level string, questionType models.QuestionType) (result0 map[string]int, err error) {
2419
20x
    return w.workerService.GetPriorityDistribution(ctx, userID, language, level, string(questionType))
2420
20x
}
2421